dexbot 1.4.7 → 1.4.8

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 (123) hide show
  1. package/README.md +1 -0
  2. package/dist/credential-daemon.d.ts.map +1 -1
  3. package/dist/credential-daemon.js +416 -92
  4. package/dist/credential-daemon.js.map +1 -1
  5. package/dist/dexbot.d.ts.map +1 -1
  6. package/dist/dexbot.js +61 -27
  7. package/dist/dexbot.js.map +1 -1
  8. package/dist/modules/account_bots.d.ts.map +1 -1
  9. package/dist/modules/account_bots.js +14 -24
  10. package/dist/modules/account_bots.js.map +1 -1
  11. package/dist/modules/bitshares-native/transport.d.ts.map +1 -1
  12. package/dist/modules/bitshares-native/transport.js +21 -0
  13. package/dist/modules/bitshares-native/transport.js.map +1 -1
  14. package/dist/modules/broadcast_failure.d.ts +21 -0
  15. package/dist/modules/broadcast_failure.d.ts.map +1 -0
  16. package/dist/modules/broadcast_failure.js +95 -0
  17. package/dist/modules/broadcast_failure.js.map +1 -0
  18. package/dist/modules/chain_keys.d.ts +2 -2
  19. package/dist/modules/chain_keys.d.ts.map +1 -1
  20. package/dist/modules/chain_keys.js +30 -56
  21. package/dist/modules/chain_keys.js.map +1 -1
  22. package/dist/modules/chain_orders.d.ts +79 -1
  23. package/dist/modules/chain_orders.d.ts.map +1 -1
  24. package/dist/modules/chain_orders.js +154 -10
  25. package/dist/modules/chain_orders.js.map +1 -1
  26. package/dist/modules/constants.d.ts +1 -0
  27. package/dist/modules/constants.d.ts.map +1 -1
  28. package/dist/modules/constants.js +21 -7
  29. package/dist/modules/constants.js.map +1 -1
  30. package/dist/modules/daemon_node_health.d.ts +33 -0
  31. package/dist/modules/daemon_node_health.d.ts.map +1 -0
  32. package/dist/modules/daemon_node_health.js +120 -0
  33. package/dist/modules/daemon_node_health.js.map +1 -0
  34. package/dist/modules/dexbot_class.d.ts +12 -3
  35. package/dist/modules/dexbot_class.d.ts.map +1 -1
  36. package/dist/modules/dexbot_class.js +19 -5
  37. package/dist/modules/dexbot_class.js.map +1 -1
  38. package/dist/modules/dexbot_cow_runtime.d.ts +46 -1
  39. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  40. package/dist/modules/dexbot_cow_runtime.js +754 -64
  41. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  42. package/dist/modules/dexbot_credential_client.d.ts +15 -0
  43. package/dist/modules/dexbot_credential_client.d.ts.map +1 -1
  44. package/dist/modules/dexbot_credential_client.js +114 -141
  45. package/dist/modules/dexbot_credential_client.js.map +1 -1
  46. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  47. package/dist/modules/dexbot_fill_runtime.js +45 -15
  48. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  49. package/dist/modules/dexbot_maintenance_runtime.d.ts +5 -1
  50. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  51. package/dist/modules/dexbot_maintenance_runtime.js +237 -84
  52. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  53. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  54. package/dist/modules/dexbot_startup_runtime.js +95 -33
  55. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  56. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  57. package/dist/modules/dexbot_state_recovery.js +24 -2
  58. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  59. package/dist/modules/key_store.d.ts.map +1 -1
  60. package/dist/modules/key_store.js +5 -4
  61. package/dist/modules/key_store.js.map +1 -1
  62. package/dist/modules/launcher/credential_daemon.d.ts.map +1 -1
  63. package/dist/modules/launcher/credential_daemon.js +15 -3
  64. package/dist/modules/launcher/credential_daemon.js.map +1 -1
  65. package/dist/modules/node_failure_ledger.d.ts +39 -0
  66. package/dist/modules/node_failure_ledger.d.ts.map +1 -0
  67. package/dist/modules/node_failure_ledger.js +88 -0
  68. package/dist/modules/node_failure_ledger.js.map +1 -0
  69. package/dist/modules/node_health_cache.d.ts +2 -1
  70. package/dist/modules/node_health_cache.d.ts.map +1 -1
  71. package/dist/modules/node_health_cache.js +25 -0
  72. package/dist/modules/node_health_cache.js.map +1 -1
  73. package/dist/modules/node_manager.d.ts +3 -0
  74. package/dist/modules/node_manager.d.ts.map +1 -1
  75. package/dist/modules/node_manager.js +41 -10
  76. package/dist/modules/node_manager.js.map +1 -1
  77. package/dist/modules/order/accounting.d.ts +1 -1
  78. package/dist/modules/order/accounting.d.ts.map +1 -1
  79. package/dist/modules/order/accounting.js +219 -101
  80. package/dist/modules/order/accounting.js.map +1 -1
  81. package/dist/modules/order/grid.d.ts.map +1 -1
  82. package/dist/modules/order/grid.js +66 -14
  83. package/dist/modules/order/grid.js.map +1 -1
  84. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  85. package/dist/modules/order/grid_reconcile.js +130 -56
  86. package/dist/modules/order/grid_reconcile.js.map +1 -1
  87. package/dist/modules/order/grid_reconcile_internal.d.ts +2 -1
  88. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  89. package/dist/modules/order/grid_reconcile_internal.js +333 -54
  90. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  91. package/dist/modules/order/manager.d.ts +97 -4
  92. package/dist/modules/order/manager.d.ts.map +1 -1
  93. package/dist/modules/order/manager.js +377 -92
  94. package/dist/modules/order/manager.js.map +1 -1
  95. package/dist/modules/order/strategy.d.ts.map +1 -1
  96. package/dist/modules/order/strategy.js +24 -1
  97. package/dist/modules/order/strategy.js.map +1 -1
  98. package/dist/modules/order/sync_engine.d.ts +16 -3
  99. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  100. package/dist/modules/order/sync_engine.js +329 -247
  101. package/dist/modules/order/sync_engine.js.map +1 -1
  102. package/dist/modules/order/utils/math.d.ts +79 -1
  103. package/dist/modules/order/utils/math.d.ts.map +1 -1
  104. package/dist/modules/order/utils/math.js +18 -0
  105. package/dist/modules/order/utils/math.js.map +1 -1
  106. package/dist/modules/order/utils/order.d.ts +44 -1
  107. package/dist/modules/order/utils/order.d.ts.map +1 -1
  108. package/dist/modules/order/utils/order.js +91 -5
  109. package/dist/modules/order/utils/order.js.map +1 -1
  110. package/dist/modules/order/utils/system.d.ts +5 -1
  111. package/dist/modules/order/utils/system.d.ts.map +1 -1
  112. package/dist/modules/order/utils/system.js +7 -5
  113. package/dist/modules/order/utils/system.js.map +1 -1
  114. package/dist/modules/order/utils/withPoolRef.d.ts.map +1 -1
  115. package/dist/modules/order/utils/withPoolRef.js +39 -4
  116. package/dist/modules/order/utils/withPoolRef.js.map +1 -1
  117. package/dist/modules/socket_json_client.d.ts +14 -0
  118. package/dist/modules/socket_json_client.d.ts.map +1 -0
  119. package/dist/modules/socket_json_client.js +100 -0
  120. package/dist/modules/socket_json_client.js.map +1 -0
  121. package/package.json +2 -1
  122. package/scripts/clear-all.sh +4 -4
  123. package/scripts/clear-logs.sh +4 -4
@@ -719,7 +719,12 @@ class SyncEngine {
719
719
  else {
720
720
  const spreadOrder = (0, order_1.convertToSpreadPlaceholder)(gridOrder);
721
721
  await mgr._applyOrderUpdate(spreadOrder, 'sync-pass1-filled', { skipAccounting: skipAccounting, fee: 0 });
722
- filledOrders.push(spreadOrder);
722
+ // Push the filled order with its REAL side (chain order
723
+ // type), not the SPREAD placeholder: downstream fill
724
+ // processing (deriveTargetBoundary) derives boundary
725
+ // movement from fill.type, and a SPREAD type would
726
+ // silently drop the shift for this completed order.
727
+ filledOrders.push({ ...gridOrder, type: chainOrder.type });
723
728
  updatedOrders.push(spreadOrder);
724
729
  }
725
730
  }
@@ -760,7 +765,7 @@ class SyncEngine {
760
765
  // this open-orders sync.
761
766
  if (mgr._committedOrderIds?.has(gridOrder.orderId)) {
762
767
  const commitAge = Date.now() - (mgr._committedOrderIdsBuiltAt || 0);
763
- const isGhost = gridOrder.isGhost === true || (gridOrder.size <= 0 && gridOrder.state === constants_1.ORDER_STATES.PARTIAL);
768
+ const isGhost = gridOrder.size <= 0 && gridOrder.state === constants_1.ORDER_STATES.PARTIAL;
764
769
  if (!isGhost && commitAge < constants_1.TIMING.SYNC_LOCK_TIMEOUT_MS) {
765
770
  continue;
766
771
  }
@@ -771,7 +776,18 @@ class SyncEngine {
771
776
  await mgr._applyOrderUpdate(spreadOrder, 'sync-cleanup-phantom', { skipAccounting: skipAccounting, fee: 0 });
772
777
  // Only genuine disappearances (had orderId) count as fills.
773
778
  if (hadOrderId) {
774
- filledOrders.push({ ...currentGridOrder });
779
+ // A SPREAD slot can carry an on-chain order (spread-correction
780
+ // activation). Resolve the real side before pushing: the fill
781
+ // drives deriveTargetBoundary, which only shifts on BUY/SELL —
782
+ // a SPREAD-typed fill would silently drop the boundary crawl
783
+ // for this completed order. Same price-vs-startPrice convention
784
+ // as the funds check (manager.ts) since no chain order remains
785
+ // to resolve the side from.
786
+ let resolvedType = currentGridOrder.type;
787
+ if (resolvedType === constants_1.ORDER_TYPES.SPREAD) {
788
+ resolvedType = (0, order_1.resolveSpreadOrderSide)(currentGridOrder.price, mgr.config.startPrice);
789
+ }
790
+ filledOrders.push({ ...currentGridOrder, type: resolvedType });
775
791
  }
776
792
  }
777
793
  }
@@ -1012,7 +1028,22 @@ class SyncEngine {
1012
1028
  return null;
1013
1029
  }
1014
1030
  _computeFillContext(mgr, matchedGridOrder, paysAssetId, paysAmountRaw) {
1015
- const orderType = matchedGridOrder.type;
1031
+ // A SPREAD slot can carry an on-chain order (e.g. spread-correction
1032
+ // activation). Resolve the real side from the fill's pays asset so the
1033
+ // transition result is BUY/SELL — never SPREAD with an on-chain state,
1034
+ // which validateOrder rejects as fatal ILLEGAL_SPREAD_STATE. When the
1035
+ // pays asset matches neither side (malformed fill event), fall back to
1036
+ // the price-vs-startPrice convention so the type can never leak SPREAD
1037
+ // into a fill result (which would drop the boundary shift downstream).
1038
+ let orderType = matchedGridOrder.type;
1039
+ if (orderType === constants_1.ORDER_TYPES.SPREAD) {
1040
+ if (paysAssetId === mgr.assets.assetB.id)
1041
+ orderType = constants_1.ORDER_TYPES.BUY;
1042
+ else if (paysAssetId === mgr.assets.assetA.id)
1043
+ orderType = constants_1.ORDER_TYPES.SELL;
1044
+ else
1045
+ orderType = (0, order_1.resolveSpreadOrderSide)(matchedGridOrder.price, mgr.config.startPrice);
1046
+ }
1016
1047
  const currentSize = toFiniteNumber(matchedGridOrder.size);
1017
1048
  const precision = (orderType === constants_1.ORDER_TYPES.SELL) ? mgr.assets.assetA.precision : mgr.assets.assetB.precision;
1018
1049
  let filledAmount = 0;
@@ -1047,39 +1078,45 @@ class SyncEngine {
1047
1078
  }
1048
1079
  const gridAlsoEmpty = currentSizeIntFromGrid <= 0;
1049
1080
  let isEffectivelyFull = resolvedChainConfirmsEmpty || gridAlsoEmpty;
1050
- let ghostOrderId;
1051
1081
  if (!isEffectivelyFull) {
1052
1082
  const otherPrecision = (orderType === constants_1.ORDER_TYPES.SELL) ? mgr.assets.assetB.precision : mgr.assets.assetA.precision;
1053
- const price = matchedGridOrder.price;
1054
- const otherSize = (orderType === constants_1.ORDER_TYPES.SELL) ? newSize * price : newSize / price;
1083
+ const otherSidePrice = matchedGridOrder.price;
1084
+ const otherSize = (orderType === constants_1.ORDER_TYPES.SELL) ? newSize * otherSidePrice : newSize / otherSidePrice;
1055
1085
  if ((0, math_1.floatToBlockchainInt)(otherSize, otherPrecision) <= 0) {
1056
- mgr.logger.log(`[SYNC] Order ${matchedGridOrder.orderId} (slot ${matchedGridOrder.id}) other-side (${otherSize}) rounds to 0. Treating as full fill to trigger rotation.`, 'info');
1086
+ // A fill event is authoritative: the order WAS on-chain and this
1087
+ // fill consumed the tradeable remainder. A fill is a fill — we do
1088
+ // not need to preserve the orderId as a "ghost" PARTIAL to block
1089
+ // a duplicate CREATE. Any residual dust that remains on-chain
1090
+ // after rounding is cancelled by the normal reconciliation /
1091
+ // duplicate-price cancel path, not by stalling the grid. Treating
1092
+ // every sub-dust other-side fill as a real full fill lets rotation
1093
+ // immediately plan the opposite-side replacement, preserving the
1094
+ // grid invariant (each filled side produces a new order on the
1095
+ // other side). This replaces the legacy ghost mechanism whose
1096
+ // preserved orderIds caused instantly-filled replacement orders
1097
+ // to deadlock the COW create pipeline.
1098
+ mgr.logger.log(`[SYNC] Order ${matchedGridOrder.orderId} (slot ${matchedGridOrder.id}) other-side (${otherSize}) rounds to 0. ` +
1099
+ `Fill is authoritative: treating as full fill for rotation; residual dust left for reconciliation cancel.`, 'info');
1057
1100
  isEffectivelyFull = true;
1058
- ghostOrderId = matchedGridOrder.orderId;
1059
1101
  }
1060
1102
  }
1061
1103
  let fullUpdate;
1062
- let ghostUpdate;
1063
1104
  let partialUpdate;
1064
1105
  let filledOrderResult;
1065
1106
  if (isEffectivelyFull) {
1066
- if (ghostOrderId) {
1067
- ghostUpdate = {
1068
- ...matchedGridOrder,
1069
- size: 0,
1070
- state: constants_1.ORDER_STATES.PARTIAL,
1071
- orderId: ghostOrderId,
1072
- isGhost: true,
1073
- };
1074
- }
1075
- else {
1076
- fullUpdate = (0, order_1.convertToSpreadPlaceholder)(matchedGridOrder);
1077
- }
1078
- filledOrderResult = { ...matchedGridOrder, blockNum, historyId, isMaker };
1107
+ fullUpdate = (0, order_1.convertToSpreadPlaceholder)(matchedGridOrder);
1108
+ filledOrderResult = {
1109
+ ...matchedGridOrder,
1110
+ type: orderType,
1111
+ blockNum,
1112
+ historyId,
1113
+ isMaker
1114
+ };
1079
1115
  }
1080
1116
  else {
1081
1117
  filledOrderResult = {
1082
1118
  ...matchedGridOrder,
1119
+ type: orderType,
1083
1120
  size: filledAmount,
1084
1121
  isPartial: true,
1085
1122
  blockNum,
@@ -1087,7 +1124,7 @@ class SyncEngine {
1087
1124
  isMaker
1088
1125
  };
1089
1126
  const { btsFeeState, ...matchedWithoutDeferredFee } = matchedGridOrder;
1090
- let updatedOrder = { ...matchedWithoutDeferredFee, state: constants_1.ORDER_STATES.PARTIAL };
1127
+ let updatedOrder = { ...matchedWithoutDeferredFee, type: orderType, state: constants_1.ORDER_STATES.PARTIAL };
1091
1128
  if (updatedOrder.rawOnChain && updatedOrder.rawOnChain.for_sale !== undefined) {
1092
1129
  const baselineForSale = (chainRefetched && Number.isFinite(effectiveRawForSale))
1093
1130
  ? effectiveRawForSale
@@ -1107,11 +1144,8 @@ class SyncEngine {
1107
1144
  }
1108
1145
  return {
1109
1146
  isFull: isEffectivelyFull,
1110
- isGhost: !!ghostOrderId,
1111
- ghostOrderId,
1112
1147
  filledOrder: filledOrderResult,
1113
1148
  fullUpdate,
1114
- ghostUpdate,
1115
1149
  partialUpdate,
1116
1150
  newSize
1117
1151
  };
@@ -1145,108 +1179,124 @@ class SyncEngine {
1145
1179
  mgr.logger.log(`[SYNC] Missing replay-safe fill key for order ${orderId} block ${blockNum}; deferring to open-orders sync`, 'warn');
1146
1180
  return { filledOrders: [], updatedOrders: [], partialFill: false, requiresOpenOrdersSync: true };
1147
1181
  }
1148
- const orderIdsToLock = new Set([orderId]);
1149
- mgr.lockOrders([...orderIdsToLock]);
1182
+ // Raise the fill-batch in-flight guard BEFORE the accountTotals refresh
1183
+ // (mirrors syncFromFillHistoryBatch): the refresh returns post-fill
1184
+ // balances while the grid still holds the filled order as committed, so
1185
+ // a fund-invariant check in this window would fire a spurious CRITICAL.
1186
+ // The guard is lowered before the final consolidated recalc so the
1187
+ // settled (re-anchored) state is still verified.
1188
+ mgr._fillBatchInFlight = (mgr._fillBatchInFlight ?? 0) + 1;
1150
1189
  try {
1151
- mgr.pauseFundRecalc();
1190
+ const totalsGate = await mgr.refreshAccountTotalsIfStale();
1191
+ if (!totalsGate.ok) {
1192
+ mgr.logger.log(`[SYNC] Deferring fill ${historyId}: accountTotals refresh failed (stale snapshot). Will be re-processed on the next cycle.`, 'warn');
1193
+ return { filledOrders: [], updatedOrders: [], partialFill: false, requiresOpenOrdersSync: false, deferred: true };
1194
+ }
1195
+ const orderIdsToLock = new Set([orderId]);
1196
+ mgr.lockOrders([...orderIdsToLock]);
1152
1197
  try {
1153
- const appliedAccounting = await mgr.accountant.processFillAccounting(fillOp, fillKey, { persistenceMode });
1154
- if (!appliedAccounting) {
1155
- mgr.logger.log(`[SYNC] Replay detected for fill ${fillKey}; skipping duplicate order mutation`, 'debug');
1156
- return { filledOrders: [], updatedOrders: [], partialFill: false };
1157
- }
1158
- const assetAPrecision = mgr.assets?.assetA?.precision;
1159
- const assetBPrecision = mgr.assets?.assetB?.precision;
1160
- if (assetAPrecision === undefined || assetBPrecision === undefined) {
1161
- mgr.logger?.log?.('Error: manager.assets precision missing in syncFromFillHistory', 'error');
1162
- return { filledOrders: [], updatedOrders: [], partialFill: false };
1163
- }
1164
- const matchedGridOrder = this._findMatchingGridOrder(mgr, orderId);
1165
- if (!matchedGridOrder) {
1166
- mgr.logger.log(`[SYNC] Fill for order ${orderId} ignored: order not found in active grid.`, 'debug');
1167
- return { filledOrders: [], updatedOrders: [], partialFill: false };
1168
- }
1169
- const ctx = this._computeFillContext(mgr, matchedGridOrder, paysAssetId, paysAmountRaw);
1170
- mgr.logger.log(`[SYNC] Order ${orderId} (${ctx.orderType}) currentSize=${ctx.currentSize}, filledAmount=${ctx.filledAmount}`, 'debug');
1171
- let effectiveRawForSale = ctx.rawForSaleInt;
1172
- let chainConfirmsEmpty = false;
1173
- let chainRefetched = false;
1174
- if (ctx.driftSignal) {
1175
- try {
1176
- const fresh = await chainOrders.readSingleOrder(orderId, 3000);
1177
- if (fresh) {
1178
- const freshForSale = toFiniteNumber(fresh.for_sale, undefined);
1179
- if (Number.isFinite(freshForSale)) {
1180
- effectiveRawForSale = freshForSale;
1181
- chainRefetched = true;
1182
- mgr.logger.log(`[SYNC] Drift detected for ${orderId} (cached=${ctx.rawForSaleInt} < grid=${ctx.currentSizeIntFromGrid}); ` +
1183
- `refetched for_sale=${freshForSale}`, 'warn');
1198
+ mgr.pauseFundRecalc();
1199
+ try {
1200
+ const appliedAccounting = await mgr.accountant.processFillAccounting(fillOp, fillKey, { persistenceMode });
1201
+ if (!appliedAccounting) {
1202
+ mgr.logger.log(`[SYNC] Replay detected for fill ${fillKey}; skipping duplicate order mutation`, 'debug');
1203
+ return { filledOrders: [], updatedOrders: [], partialFill: false };
1204
+ }
1205
+ const assetAPrecision = mgr.assets?.assetA?.precision;
1206
+ const assetBPrecision = mgr.assets?.assetB?.precision;
1207
+ if (assetAPrecision === undefined || assetBPrecision === undefined) {
1208
+ mgr.logger?.log?.('Error: manager.assets precision missing in syncFromFillHistory', 'error');
1209
+ return { filledOrders: [], updatedOrders: [], partialFill: false };
1210
+ }
1211
+ const matchedGridOrder = this._findMatchingGridOrder(mgr, orderId);
1212
+ if (!matchedGridOrder) {
1213
+ mgr.logger.log(`[SYNC] Fill for order ${orderId} ignored: order not found in active grid.`, 'debug');
1214
+ return { filledOrders: [], updatedOrders: [], partialFill: false };
1215
+ }
1216
+ const ctx = this._computeFillContext(mgr, matchedGridOrder, paysAssetId, paysAmountRaw);
1217
+ mgr.logger.log(`[SYNC] Order ${orderId} (${ctx.orderType}) currentSize=${ctx.currentSize}, filledAmount=${ctx.filledAmount}`, 'debug');
1218
+ let effectiveRawForSale = ctx.rawForSaleInt;
1219
+ let chainConfirmsEmpty = false;
1220
+ let chainRefetched = false;
1221
+ if (ctx.driftSignal) {
1222
+ try {
1223
+ const fresh = await chainOrders.readSingleOrder(orderId, 3000);
1224
+ if (fresh) {
1225
+ const freshForSale = toFiniteNumber(fresh.for_sale, undefined);
1226
+ if (Number.isFinite(freshForSale)) {
1227
+ effectiveRawForSale = freshForSale;
1228
+ chainRefetched = true;
1229
+ mgr.logger.log(`[SYNC] Drift detected for ${orderId} (cached=${ctx.rawForSaleInt} < grid=${ctx.currentSizeIntFromGrid}); ` +
1230
+ `refetched for_sale=${freshForSale}`, 'warn');
1231
+ }
1232
+ }
1233
+ else {
1234
+ chainConfirmsEmpty = true;
1235
+ mgr.logger.log(`[SYNC] Drift refetch for ${orderId} returned null; chain confirms empty`, 'info');
1184
1236
  }
1185
1237
  }
1186
- else {
1187
- chainConfirmsEmpty = true;
1188
- mgr.logger.log(`[SYNC] Drift refetch for ${orderId} returned null; chain confirms empty`, 'info');
1238
+ catch (refetchErr) {
1239
+ mgr.logger.log(`[SYNC] Drift refetch for ${orderId} failed; falling back to cache: ${refetchErr?.message || refetchErr}`, 'warn');
1189
1240
  }
1190
1241
  }
1191
- catch (refetchErr) {
1192
- mgr.logger.log(`[SYNC] Drift refetch for ${orderId} failed; falling back to cache: ${refetchErr?.message || refetchErr}`, 'warn');
1193
- }
1194
- }
1195
- const result = await this._computeFillTransitionResult(mgr, {
1196
- matchedGridOrder,
1197
- orderType: ctx.orderType,
1198
- precision: ctx.precision,
1199
- filledAmount: ctx.filledAmount,
1200
- filledAmountInt: (0, math_1.floatToBlockchainInt)(ctx.filledAmount, ctx.precision),
1201
- currentSizeIntFromGrid: ctx.currentSizeIntFromGrid,
1202
- rawForSaleInt: ctx.rawForSaleInt,
1203
- chainRefetched,
1204
- chainConfirmsEmpty,
1205
- effectiveRawForSale,
1206
- blockNum,
1207
- historyId,
1208
- isMaker
1209
- });
1210
- const filledOrders = [];
1211
- const updatedOrders = [];
1212
- if (result.isFull) {
1213
- if (result.isGhost) {
1214
- mgr.logger.log(`[SYNC] Ghost full fill for order ${orderId} (slot ${matchedGridOrder.id}): preserving orderId ${result.ghostOrderId} as PARTIAL to block duplicate CREATE.`, 'info');
1215
- const ghostOk = await mgr._updateOrder(result.ghostUpdate, 'handle-fill-ghost', { skipAccounting: false, fee: 0 });
1216
- if (ghostOk === false) {
1217
- mgr.logger.log(`[SYNC] Failed to apply ghost fill state for order ${orderId}; marking totals stale for next sync cycle`, 'warn');
1242
+ const result = await this._computeFillTransitionResult(mgr, {
1243
+ matchedGridOrder,
1244
+ orderType: ctx.orderType,
1245
+ precision: ctx.precision,
1246
+ filledAmount: ctx.filledAmount,
1247
+ filledAmountInt: (0, math_1.floatToBlockchainInt)(ctx.filledAmount, ctx.precision),
1248
+ currentSizeIntFromGrid: ctx.currentSizeIntFromGrid,
1249
+ rawForSaleInt: ctx.rawForSaleInt,
1250
+ chainRefetched,
1251
+ chainConfirmsEmpty,
1252
+ effectiveRawForSale,
1253
+ blockNum,
1254
+ historyId,
1255
+ isMaker
1256
+ });
1257
+ const filledOrders = [];
1258
+ const updatedOrders = [];
1259
+ if (result.isFull) {
1260
+ mgr.logger.log(`[SYNC] Full fill for order ${orderId} (slot ${matchedGridOrder.id}).`, 'info');
1261
+ const fullOk = await mgr._updateOrder(result.fullUpdate, 'handle-fill-full', { skipAccounting: false, fee: 0 });
1262
+ if (fullOk === false) {
1263
+ mgr.logger.log(`[SYNC] Failed to convert filled order ${orderId} to spread placeholder; marking totals stale for next sync cycle`, 'warn');
1218
1264
  mgr.accountTotalsStale = true;
1219
1265
  }
1220
1266
  filledOrders.push(result.filledOrder);
1221
- return { filledOrders, updatedOrders, partialFill: false };
1222
1267
  }
1223
- mgr.logger.log(`[SYNC] Full fill for order ${orderId} (slot ${matchedGridOrder.id}).`, 'info');
1224
- const fullOk = await mgr._updateOrder(result.fullUpdate, 'handle-fill-full', { skipAccounting: false, fee: 0 });
1225
- if (fullOk === false) {
1226
- mgr.logger.log(`[SYNC] Failed to convert filled order ${orderId} to spread placeholder; marking totals stale for next sync cycle`, 'warn');
1227
- mgr.accountTotalsStale = true;
1268
+ else {
1269
+ mgr.logger.log(`[SYNC] Partial fill for order ${orderId} (slot ${matchedGridOrder.id}): newSize=${result.newSize}`, 'info');
1270
+ const partialOk = await mgr._updateOrder(result.partialUpdate, 'handle-fill-partial', { skipAccounting: false, fee: 0 });
1271
+ if (partialOk === false) {
1272
+ mgr.logger.log(`[SYNC] Failed to update partially filled order ${orderId}; marking totals stale for next sync cycle`, 'warn');
1273
+ mgr.accountTotalsStale = true;
1274
+ }
1275
+ updatedOrders.push(result.partialUpdate);
1276
+ filledOrders.push(result.filledOrder);
1228
1277
  }
1229
- filledOrders.push(result.filledOrder);
1230
- return { filledOrders, updatedOrders, partialFill: false };
1278
+ // Re-anchor accountTotals to authoritative post-fill chain truth
1279
+ // after the fill's optimistic accounting + grid update (see
1280
+ // syncFromFillHistoryBatch for rationale).
1281
+ await mgr.reanchorAccountTotals('fill-sync');
1282
+ return { filledOrders, updatedOrders, partialFill: result.isFull ? false : true };
1231
1283
  }
1232
- else {
1233
- mgr.logger.log(`[SYNC] Partial fill for order ${orderId} (slot ${matchedGridOrder.id}): newSize=${result.newSize}`, 'info');
1234
- const partialOk = await mgr._updateOrder(result.partialUpdate, 'handle-fill-partial', { skipAccounting: false, fee: 0 });
1235
- if (partialOk === false) {
1236
- mgr.logger.log(`[SYNC] Failed to update partially filled order ${orderId}; marking totals stale for next sync cycle`, 'warn');
1237
- mgr.accountTotalsStale = true;
1238
- }
1239
- updatedOrders.push(result.partialUpdate);
1240
- filledOrders.push(result.filledOrder);
1241
- return { filledOrders, updatedOrders, partialFill: true };
1284
+ finally {
1285
+ // Lower the guard before the final consolidated recalc so the
1286
+ // settled (re-anchored) state is still verified.
1287
+ mgr._fillBatchInFlight = Math.max(0, (mgr._fillBatchInFlight ?? 0) - 1);
1288
+ await mgr.resumeFundRecalc();
1242
1289
  }
1243
1290
  }
1244
1291
  finally {
1245
- await mgr.resumeFundRecalc();
1292
+ mgr.unlockOrders([...orderIdsToLock]);
1246
1293
  }
1247
1294
  }
1248
1295
  finally {
1249
- mgr.unlockOrders([...orderIdsToLock]);
1296
+ // Safety net: ensure the guard never leaks across cycles on early
1297
+ // returns (deferral) or exceptions in the refresh/lock region.
1298
+ if ((mgr._fillBatchInFlight ?? 0) > 0)
1299
+ mgr._fillBatchInFlight--;
1250
1300
  }
1251
1301
  }
1252
1302
  /**
@@ -1299,172 +1349,197 @@ class SyncEngine {
1299
1349
  }
1300
1350
  // Phase 2: Lock all unique order IDs once
1301
1351
  const allOrderIds = [...new Set(fillEntries.map(e => e.orderId))];
1302
- mgr.lockOrders(allOrderIds);
1352
+ // Raise the fill-batch in-flight guard BEFORE the accountTotals refresh.
1353
+ // The refresh fetches post-fill balances, but the grid still holds the
1354
+ // filled orders as committed until the batch's grid mutation (Phase 7),
1355
+ // so any fund-invariant check in this window would fire a spurious
1356
+ // CRITICAL with diff == the fills' size. The guard is lowered right
1357
+ // before the final consolidated recalc so the settled state is still
1358
+ // verified.
1359
+ mgr._fillBatchInFlight = (mgr._fillBatchInFlight ?? 0) + 1;
1303
1360
  try {
1304
- mgr.pauseFundRecalc();
1361
+ const totalsGate = await mgr.refreshAccountTotalsIfStale();
1362
+ if (!totalsGate.ok) {
1363
+ mgr.logger.log('[SYNC] Deferring fill batch: accountTotals refresh failed (stale snapshot). Fills will be re-processed on the next cycle.', 'warn');
1364
+ return { filledOrders: [], updatedOrders: [], partialFill: false, requiresOpenOrdersSync: false, deferred: true };
1365
+ }
1366
+ mgr.lockOrders(allOrderIds);
1305
1367
  try {
1306
- // Phase 3: Process accounting for each fill
1307
- const validEntries = [];
1308
- for (const entry of fillEntries) {
1309
- try {
1310
- const appliedAccounting = await mgr.accountant.processFillAccounting(entry.fillOp, entry.fillKey, { persistenceMode });
1311
- if (!appliedAccounting) {
1312
- mgr.logger.log(`[SYNC] Replay detected for fill ${entry.fillKey}; skipping duplicate order mutation`, 'debug');
1368
+ mgr.pauseFundRecalc();
1369
+ try {
1370
+ // Phase 3: Process accounting for each fill
1371
+ const validEntries = [];
1372
+ for (const entry of fillEntries) {
1373
+ try {
1374
+ const appliedAccounting = await mgr.accountant.processFillAccounting(entry.fillOp, entry.fillKey, { persistenceMode });
1375
+ if (!appliedAccounting) {
1376
+ mgr.logger.log(`[SYNC] Replay detected for fill ${entry.fillKey}; skipping duplicate order mutation`, 'debug');
1377
+ continue;
1378
+ }
1379
+ validEntries.push(entry);
1380
+ }
1381
+ catch (acctErr) {
1382
+ mgr.logger.log(`[SYNC] Accounting error for fill ${entry.fillKey}: ${(0, errors_1.getErrorMessage)(acctErr)}`, 'error');
1313
1383
  continue;
1314
1384
  }
1315
- validEntries.push(entry);
1316
1385
  }
1317
- catch (acctErr) {
1318
- mgr.logger.log(`[SYNC] Accounting error for fill ${entry.fillKey}: ${(0, errors_1.getErrorMessage)(acctErr)}`, 'error');
1319
- continue;
1386
+ if (validEntries.length === 0) {
1387
+ return { filledOrders: [], updatedOrders: [], partialFill: false, requiresOpenOrdersSync: anyRequiresSync };
1320
1388
  }
1321
- }
1322
- if (validEntries.length === 0) {
1323
- return { filledOrders: [], updatedOrders: [], partialFill: false, requiresOpenOrdersSync: anyRequiresSync };
1324
- }
1325
- // Phase 4: Build per-fill context + identify drift candidates
1326
- const entryContexts = [];
1327
- const driftOrderIds = new Set();
1328
- const assetAPrecision = mgr.assets?.assetA?.precision;
1329
- const assetBPrecision = mgr.assets?.assetB?.precision;
1330
- if (assetAPrecision === undefined || assetBPrecision === undefined) {
1331
- mgr.logger?.log?.('Error: manager.assets precision missing in syncFromFillHistoryBatch', 'error');
1332
- return { filledOrders: [], updatedOrders: [], partialFill: false, requiresOpenOrdersSync: anyRequiresSync };
1333
- }
1334
- for (const entry of validEntries) {
1335
- const { orderId, paysAmountRaw, paysAssetId } = entry;
1336
- const matchedGridOrder = this._findMatchingGridOrder(mgr, orderId);
1337
- if (!matchedGridOrder) {
1338
- mgr.logger.log(`[SYNC] Fill for order ${orderId} ignored: order not found in active grid.`, 'debug');
1339
- anyRequiresSync = true;
1340
- continue;
1389
+ // Phase 4: Build per-fill context + identify drift candidates
1390
+ const entryContexts = [];
1391
+ const driftOrderIds = new Set();
1392
+ const assetAPrecision = mgr.assets?.assetA?.precision;
1393
+ const assetBPrecision = mgr.assets?.assetB?.precision;
1394
+ if (assetAPrecision === undefined || assetBPrecision === undefined) {
1395
+ mgr.logger?.log?.('Error: manager.assets precision missing in syncFromFillHistoryBatch', 'error');
1396
+ return { filledOrders: [], updatedOrders: [], partialFill: false, requiresOpenOrdersSync: anyRequiresSync };
1341
1397
  }
1342
- const ctx = this._computeFillContext(mgr, matchedGridOrder, paysAssetId, paysAmountRaw);
1343
- mgr.logger.log(`[SYNC] Order ${orderId} (${ctx.orderType}) currentSize=${ctx.currentSize}, filledAmount=${ctx.filledAmount}`, 'debug');
1344
- if (ctx.driftSignal) {
1345
- driftOrderIds.add(orderId);
1398
+ for (const entry of validEntries) {
1399
+ const { orderId, paysAmountRaw, paysAssetId } = entry;
1400
+ const matchedGridOrder = this._findMatchingGridOrder(mgr, orderId);
1401
+ if (!matchedGridOrder) {
1402
+ mgr.logger.log(`[SYNC] Fill for order ${orderId} ignored: order not found in active grid.`, 'debug');
1403
+ anyRequiresSync = true;
1404
+ continue;
1405
+ }
1406
+ const ctx = this._computeFillContext(mgr, matchedGridOrder, paysAssetId, paysAmountRaw);
1407
+ mgr.logger.log(`[SYNC] Order ${orderId} (${ctx.orderType}) currentSize=${ctx.currentSize}, filledAmount=${ctx.filledAmount}`, 'debug');
1408
+ if (ctx.driftSignal) {
1409
+ driftOrderIds.add(orderId);
1410
+ }
1411
+ entryContexts.push({
1412
+ entry, matchedGridOrder,
1413
+ orderType: ctx.orderType,
1414
+ precision: ctx.precision,
1415
+ filledAmount: ctx.filledAmount,
1416
+ filledAmountInt: (0, math_1.floatToBlockchainInt)(ctx.filledAmount, ctx.precision),
1417
+ currentSizeIntFromGrid: ctx.currentSizeIntFromGrid,
1418
+ rawForSaleInt: ctx.rawForSaleInt
1419
+ });
1346
1420
  }
1347
- entryContexts.push({
1348
- entry, matchedGridOrder,
1349
- orderType: ctx.orderType,
1350
- precision: ctx.precision,
1351
- filledAmount: ctx.filledAmount,
1352
- filledAmountInt: (0, math_1.floatToBlockchainInt)(ctx.filledAmount, ctx.precision),
1353
- currentSizeIntFromGrid: ctx.currentSizeIntFromGrid,
1354
- rawForSaleInt: ctx.rawForSaleInt
1355
- });
1356
- }
1357
- // Phase 5: Batch drift refetch — one get_objects call for all order IDs
1358
- const refetchMap = new Map();
1359
- if (driftOrderIds.size > 0) {
1360
- try {
1361
- const batchResults = await chainOrders.batchReadOrders([...driftOrderIds], 3000);
1362
- for (const [orderId, freshOrder] of batchResults) {
1363
- if (freshOrder) {
1364
- const freshForSale = toFiniteNumber(freshOrder.for_sale, undefined);
1365
- if (Number.isFinite(freshForSale)) {
1366
- refetchMap.set(orderId, {
1367
- chainConfirmsEmpty: Math.round(freshForSale) <= 0,
1368
- chainRefetched: true,
1369
- effectiveRawForSale: freshForSale
1370
- });
1421
+ // Phase 5: Batch drift refetch — one get_objects call for all order IDs
1422
+ const refetchMap = new Map();
1423
+ if (driftOrderIds.size > 0) {
1424
+ try {
1425
+ const batchResults = await chainOrders.batchReadOrders([...driftOrderIds], 3000);
1426
+ for (const [orderId, freshOrder] of batchResults) {
1427
+ if (freshOrder) {
1428
+ const freshForSale = toFiniteNumber(freshOrder.for_sale, undefined);
1429
+ if (Number.isFinite(freshForSale)) {
1430
+ refetchMap.set(orderId, {
1431
+ chainConfirmsEmpty: Math.round(freshForSale) <= 0,
1432
+ chainRefetched: true,
1433
+ effectiveRawForSale: freshForSale
1434
+ });
1435
+ }
1436
+ else {
1437
+ refetchMap.set(orderId, {
1438
+ chainConfirmsEmpty: false,
1439
+ chainRefetched: true,
1440
+ effectiveRawForSale: null
1441
+ });
1442
+ }
1371
1443
  }
1372
1444
  else {
1373
1445
  refetchMap.set(orderId, {
1374
- chainConfirmsEmpty: false,
1446
+ chainConfirmsEmpty: true,
1375
1447
  chainRefetched: true,
1376
1448
  effectiveRawForSale: null
1377
1449
  });
1378
1450
  }
1379
1451
  }
1380
- else {
1381
- refetchMap.set(orderId, {
1382
- chainConfirmsEmpty: true,
1383
- chainRefetched: true,
1384
- effectiveRawForSale: null
1385
- });
1452
+ for (const [orderId, { chainRefetched, chainConfirmsEmpty, effectiveRawForSale }] of refetchMap) {
1453
+ if (chainRefetched && effectiveRawForSale != null) {
1454
+ mgr.logger.log(`[SYNC] Drift detected for ${orderId}; batch-refetched for_sale=${effectiveRawForSale}`, 'warn');
1455
+ }
1456
+ else if (chainConfirmsEmpty) {
1457
+ mgr.logger.log(`[SYNC] Drift refetch for ${orderId} returned null; chain confirms empty`, 'info');
1458
+ }
1386
1459
  }
1387
1460
  }
1388
- for (const [orderId, { chainRefetched, chainConfirmsEmpty, effectiveRawForSale }] of refetchMap) {
1389
- if (chainRefetched && effectiveRawForSale != null) {
1390
- mgr.logger.log(`[SYNC] Drift detected for ${orderId}; batch-refetched for_sale=${effectiveRawForSale}`, 'warn');
1391
- }
1392
- else if (chainConfirmsEmpty) {
1393
- mgr.logger.log(`[SYNC] Drift refetch for ${orderId} returned null; chain confirms empty`, 'info');
1394
- }
1461
+ catch (refetchErr) {
1462
+ mgr.logger.log(`[SYNC] Batch drift refetch failed for ${driftOrderIds.size} orders; falling back to cache: ${refetchErr?.message || refetchErr}`, 'warn');
1395
1463
  }
1396
1464
  }
1397
- catch (refetchErr) {
1398
- mgr.logger.log(`[SYNC] Batch drift refetch failed for ${driftOrderIds.size} orders; falling back to cache: ${refetchErr?.message || refetchErr}`, 'warn');
1399
- }
1400
- }
1401
- // Phase 6: Compute state transitions and collect grid updates
1402
- const gridUpdates = [];
1403
- const filledOrders = [];
1404
- const updatedOrders = [];
1405
- let anyPartialFill = false;
1406
- for (const ctx of entryContexts) {
1407
- const { entry, matchedGridOrder, orderType, precision, filledAmount, filledAmountInt, currentSizeIntFromGrid, rawForSaleInt } = ctx;
1408
- const { orderId, blockNum, historyId, isMaker } = entry;
1409
- const refetchInfo = refetchMap.get(orderId);
1410
- const chainRefetched = refetchInfo?.chainRefetched || false;
1411
- const chainConfirmsEmpty = refetchInfo?.chainConfirmsEmpty || false;
1412
- const effectiveRawForSale = refetchInfo?.effectiveRawForSale != null ? refetchInfo.effectiveRawForSale : rawForSaleInt;
1413
- const result = await this._computeFillTransitionResult(mgr, {
1414
- matchedGridOrder,
1415
- orderType,
1416
- precision,
1417
- filledAmount,
1418
- filledAmountInt,
1419
- currentSizeIntFromGrid,
1420
- rawForSaleInt,
1421
- chainRefetched,
1422
- chainConfirmsEmpty,
1423
- effectiveRawForSale,
1424
- blockNum,
1425
- historyId,
1426
- isMaker
1427
- });
1428
- if (result.isFull) {
1429
- if (result.isGhost) {
1430
- mgr.logger.log(`[SYNC] Ghost full fill for order ${orderId} (slot ${matchedGridOrder.id}): ` +
1431
- `preserving orderId ${result.ghostOrderId} as PARTIAL to block duplicate CREATE.`, 'info');
1432
- gridUpdates.push({ id: matchedGridOrder.id, ...result.ghostUpdate, context: 'handle-fill-ghost' });
1433
- }
1434
- else {
1465
+ // Phase 6: Compute state transitions and collect grid updates
1466
+ const gridUpdates = [];
1467
+ const filledOrders = [];
1468
+ const updatedOrders = [];
1469
+ let anyPartialFill = false;
1470
+ for (const ctx of entryContexts) {
1471
+ const { entry, matchedGridOrder, orderType, precision, filledAmount, filledAmountInt, currentSizeIntFromGrid, rawForSaleInt } = ctx;
1472
+ const { orderId, blockNum, historyId, isMaker } = entry;
1473
+ const refetchInfo = refetchMap.get(orderId);
1474
+ const chainRefetched = refetchInfo?.chainRefetched || false;
1475
+ const chainConfirmsEmpty = refetchInfo?.chainConfirmsEmpty || false;
1476
+ const effectiveRawForSale = refetchInfo?.effectiveRawForSale != null ? refetchInfo.effectiveRawForSale : rawForSaleInt;
1477
+ const result = await this._computeFillTransitionResult(mgr, {
1478
+ matchedGridOrder,
1479
+ orderType,
1480
+ precision,
1481
+ filledAmount,
1482
+ filledAmountInt,
1483
+ currentSizeIntFromGrid,
1484
+ rawForSaleInt,
1485
+ chainRefetched,
1486
+ chainConfirmsEmpty,
1487
+ effectiveRawForSale,
1488
+ blockNum,
1489
+ historyId,
1490
+ isMaker
1491
+ });
1492
+ if (result.isFull) {
1435
1493
  mgr.logger.log(`[SYNC] Full fill for order ${orderId} (slot ${matchedGridOrder.id}).`, 'info');
1436
1494
  gridUpdates.push({ id: matchedGridOrder.id, ...result.fullUpdate, context: 'handle-fill-full' });
1495
+ filledOrders.push(result.filledOrder);
1496
+ }
1497
+ else {
1498
+ mgr.logger.log(`[SYNC] Partial fill for order ${orderId} (slot ${matchedGridOrder.id}): newSize=${result.newSize}`, 'info');
1499
+ gridUpdates.push({ id: matchedGridOrder.id, ...result.partialUpdate, context: 'handle-fill-partial' });
1500
+ updatedOrders.push(result.partialUpdate);
1501
+ filledOrders.push(result.filledOrder);
1502
+ anyPartialFill = true;
1437
1503
  }
1438
- filledOrders.push(result.filledOrder);
1439
1504
  }
1440
- else {
1441
- mgr.logger.log(`[SYNC] Partial fill for order ${orderId} (slot ${matchedGridOrder.id}): newSize=${result.newSize}`, 'info');
1442
- gridUpdates.push({ id: matchedGridOrder.id, ...result.partialUpdate, context: 'handle-fill-partial' });
1443
- updatedOrders.push(result.partialUpdate);
1444
- filledOrders.push(result.filledOrder);
1445
- anyPartialFill = true;
1505
+ // Phase 7: Single grid batch update — acquires _gridLock once
1506
+ if (gridUpdates.length > 0) {
1507
+ const updateObjects = gridUpdates.map(u => {
1508
+ const { context, ...orderData } = u;
1509
+ return orderData;
1510
+ });
1511
+ const batchOk = await mgr.applyGridUpdateBatch(updateObjects, 'handle-fill-batch', { skipAccounting: false, fee: 0 });
1512
+ if (batchOk === false) {
1513
+ mgr.logger.log('[SYNC] Batch grid update failed for some fills; marking totals stale for next sync cycle', 'warn');
1514
+ mgr.accountTotalsStale = true;
1515
+ }
1446
1516
  }
1517
+ // Re-anchor accountTotals to authoritative post-fill chain truth
1518
+ // after the batch's accounting + grid mutation. The up-front
1519
+ // stale-totals refresh returns post-fill balances, so the batch's
1520
+ // processFillAccounting pays/receives adjustments would otherwise
1521
+ // double-count; the forced fetch overwrites them with the
1522
+ // authoritative values and hands re-commitments
1523
+ // (tryDeductFromChainFree) a fresh snapshot.
1524
+ await mgr.reanchorAccountTotals('fill-batch');
1525
+ return { filledOrders, updatedOrders, partialFill: anyPartialFill, requiresOpenOrdersSync: anyRequiresSync };
1447
1526
  }
1448
- // Phase 7: Single grid batch update — acquires _gridLock once
1449
- if (gridUpdates.length > 0) {
1450
- const updateObjects = gridUpdates.map(u => {
1451
- const { context, ...orderData } = u;
1452
- return orderData;
1453
- });
1454
- const batchOk = await mgr.applyGridUpdateBatch(updateObjects, 'handle-fill-batch', { skipAccounting: false, fee: 0 });
1455
- if (batchOk === false) {
1456
- mgr.logger.log('[SYNC] Batch grid update failed for some fills; marking totals stale for next sync cycle', 'warn');
1457
- mgr.accountTotalsStale = true;
1458
- }
1527
+ finally {
1528
+ // Lower the guard before the final consolidated recalc so the
1529
+ // settled (re-anchored) state is still verified.
1530
+ mgr._fillBatchInFlight = Math.max(0, (mgr._fillBatchInFlight ?? 0) - 1);
1531
+ await mgr.resumeFundRecalc();
1459
1532
  }
1460
- return { filledOrders, updatedOrders, partialFill: anyPartialFill, requiresOpenOrdersSync: anyRequiresSync };
1461
1533
  }
1462
1534
  finally {
1463
- await mgr.resumeFundRecalc();
1535
+ mgr.unlockOrders(allOrderIds);
1464
1536
  }
1465
1537
  }
1466
1538
  finally {
1467
- mgr.unlockOrders(allOrderIds);
1539
+ // Safety net: ensure the guard never leaks across cycles on early
1540
+ // returns (deferral) or exceptions in the refresh/lock region.
1541
+ if ((mgr._fillBatchInFlight ?? 0) > 0)
1542
+ mgr._fillBatchInFlight--;
1468
1543
  }
1469
1544
  }
1470
1545
  /**
@@ -1555,14 +1630,21 @@ class SyncEngine {
1555
1630
  }
1556
1631
  }
1557
1632
  const newState = isPartialPlacement ? constants_1.ORDER_STATES.PARTIAL : constants_1.ORDER_STATES.ACTIVE;
1558
- const normalizedExpectedType = (expectedType === constants_1.ORDER_TYPES.BUY || expectedType === constants_1.ORDER_TYPES.SELL)
1633
+ let normalizedExpectedType = (expectedType === constants_1.ORDER_TYPES.BUY || expectedType === constants_1.ORDER_TYPES.SELL)
1559
1634
  ? expectedType
1560
1635
  : null;
1636
+ // Defensive: a SPREAD slot must never transition to an on-chain
1637
+ // state (validateOrder rejects SPREAD+ACTIVE/PARTIAL as fatal).
1638
+ // Derive the side from the slot price vs start price — same
1639
+ // convention as the funds check (manager.ts).
1640
+ if (!normalizedExpectedType && gridOrder.type === constants_1.ORDER_TYPES.SPREAD) {
1641
+ normalizedExpectedType = (0, order_1.resolveSpreadOrderSide)(gridOrder.price, mgr.config.startPrice);
1642
+ }
1561
1643
  const updatedOrder = {
1562
1644
  ...gridOrder,
1563
1645
  type: normalizedExpectedType || gridOrder.type,
1564
1646
  state: newState,
1565
- orderId: chainOrderId
1647
+ orderId: chainOrderId,
1566
1648
  };
1567
1649
  // Restore btsFeeState from raw chain order data if provided.
1568
1650
  // After a grid reset, in-memory orders have no fee state but the