dexbot 1.4.6 → 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 +238 -85
  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 +26 -20
  78. package/dist/modules/order/accounting.d.ts.map +1 -1
  79. package/dist/modules/order/accounting.js +281 -139
  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 +136 -56
  86. package/dist/modules/order/grid_reconcile.js.map +1 -1
  87. package/dist/modules/order/grid_reconcile_internal.d.ts +6 -5
  88. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  89. package/dist/modules/order/grid_reconcile_internal.js +388 -61
  90. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  91. package/dist/modules/order/manager.d.ts +98 -4
  92. package/dist/modules/order/manager.d.ts.map +1 -1
  93. package/dist/modules/order/manager.js +379 -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 +341 -253
  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
@@ -392,6 +392,18 @@ class SyncEngine {
392
392
  const preSyncCorrectionLen = Array.isArray(mgr.ordersNeedingPriceCorrection)
393
393
  ? mgr.ordersNeedingPriceCorrection.length
394
394
  : 0;
395
+ // If fill accounting applied balances but the order update
396
+ // failed, flag forces a fresh chain-balance fetch to correct
397
+ // the negative free balance before the next sync cycle.
398
+ if (mgr.accountTotalsStale) {
399
+ mgr.accountTotalsStale = false;
400
+ try {
401
+ await mgr.fetchAccountTotals(mgr.accountId || mgr.config?.accountId);
402
+ }
403
+ catch (fetchErr) {
404
+ mgr.logger?.log?.(`[SYNC] Stale-totals refresh failed: ${(0, errors_1.getErrorMessage)(fetchErr)}`, 'warn');
405
+ }
406
+ }
395
407
  const innerResult = await this._doSyncFromOpenOrders(chainOrders, options);
396
408
  // Re-check generation after sync completes: if forceRelease fired
397
409
  // during the sync, this result is orphaned — discard it.
@@ -707,7 +719,12 @@ class SyncEngine {
707
719
  else {
708
720
  const spreadOrder = (0, order_1.convertToSpreadPlaceholder)(gridOrder);
709
721
  await mgr._applyOrderUpdate(spreadOrder, 'sync-pass1-filled', { skipAccounting: skipAccounting, fee: 0 });
710
- 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 });
711
728
  updatedOrders.push(spreadOrder);
712
729
  }
713
730
  }
@@ -759,7 +776,18 @@ class SyncEngine {
759
776
  await mgr._applyOrderUpdate(spreadOrder, 'sync-cleanup-phantom', { skipAccounting: skipAccounting, fee: 0 });
760
777
  // Only genuine disappearances (had orderId) count as fills.
761
778
  if (hadOrderId) {
762
- 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 });
763
791
  }
764
792
  }
765
793
  }
@@ -1000,7 +1028,22 @@ class SyncEngine {
1000
1028
  return null;
1001
1029
  }
1002
1030
  _computeFillContext(mgr, matchedGridOrder, paysAssetId, paysAmountRaw) {
1003
- 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
+ }
1004
1047
  const currentSize = toFiniteNumber(matchedGridOrder.size);
1005
1048
  const precision = (orderType === constants_1.ORDER_TYPES.SELL) ? mgr.assets.assetA.precision : mgr.assets.assetB.precision;
1006
1049
  let filledAmount = 0;
@@ -1035,49 +1078,45 @@ class SyncEngine {
1035
1078
  }
1036
1079
  const gridAlsoEmpty = currentSizeIntFromGrid <= 0;
1037
1080
  let isEffectivelyFull = resolvedChainConfirmsEmpty || gridAlsoEmpty;
1038
- let ghostOrderId;
1039
- // P4: Inline dust detection — if remaining size is below the minimum
1040
- // absolute order size, treat as full fill to trigger rotation instead
1041
- // of leaving a sub-minimum remnant on chain that would accumulate as dust.
1042
- if (!isEffectivelyFull && newSize > 0) {
1043
- const minSize = (0, math_1.getMinAbsoluteOrderSize)(orderType, mgr.assets);
1044
- if (newSize < minSize) {
1045
- mgr.logger.log(`[SYNC] Order ${matchedGridOrder.orderId} (slot ${matchedGridOrder.id}) ` +
1046
- `remaining size ${newSize} below minimum ${minSize}. Treating as full fill.`, 'info');
1047
- isEffectivelyFull = true;
1048
- }
1049
- }
1050
1081
  if (!isEffectivelyFull) {
1051
1082
  const otherPrecision = (orderType === constants_1.ORDER_TYPES.SELL) ? mgr.assets.assetB.precision : mgr.assets.assetA.precision;
1052
- const price = matchedGridOrder.price;
1053
- 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;
1054
1085
  if ((0, math_1.floatToBlockchainInt)(otherSize, otherPrecision) <= 0) {
1055
- 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');
1056
1100
  isEffectivelyFull = true;
1057
- ghostOrderId = matchedGridOrder.orderId;
1058
1101
  }
1059
1102
  }
1060
1103
  let fullUpdate;
1061
- let ghostUpdate;
1062
1104
  let partialUpdate;
1063
1105
  let filledOrderResult;
1064
1106
  if (isEffectivelyFull) {
1065
- if (ghostOrderId) {
1066
- ghostUpdate = {
1067
- ...matchedGridOrder,
1068
- size: 0,
1069
- state: constants_1.ORDER_STATES.PARTIAL,
1070
- orderId: ghostOrderId,
1071
- };
1072
- }
1073
- else {
1074
- fullUpdate = (0, order_1.convertToSpreadPlaceholder)(matchedGridOrder);
1075
- }
1076
- 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
+ };
1077
1115
  }
1078
1116
  else {
1079
1117
  filledOrderResult = {
1080
1118
  ...matchedGridOrder,
1119
+ type: orderType,
1081
1120
  size: filledAmount,
1082
1121
  isPartial: true,
1083
1122
  blockNum,
@@ -1085,7 +1124,7 @@ class SyncEngine {
1085
1124
  isMaker
1086
1125
  };
1087
1126
  const { btsFeeState, ...matchedWithoutDeferredFee } = matchedGridOrder;
1088
- let updatedOrder = { ...matchedWithoutDeferredFee, state: constants_1.ORDER_STATES.PARTIAL };
1127
+ let updatedOrder = { ...matchedWithoutDeferredFee, type: orderType, state: constants_1.ORDER_STATES.PARTIAL };
1089
1128
  if (updatedOrder.rawOnChain && updatedOrder.rawOnChain.for_sale !== undefined) {
1090
1129
  const baselineForSale = (chainRefetched && Number.isFinite(effectiveRawForSale))
1091
1130
  ? effectiveRawForSale
@@ -1105,11 +1144,8 @@ class SyncEngine {
1105
1144
  }
1106
1145
  return {
1107
1146
  isFull: isEffectivelyFull,
1108
- isGhost: !!ghostOrderId,
1109
- ghostOrderId,
1110
1147
  filledOrder: filledOrderResult,
1111
1148
  fullUpdate,
1112
- ghostUpdate,
1113
1149
  partialUpdate,
1114
1150
  newSize
1115
1151
  };
@@ -1143,105 +1179,124 @@ class SyncEngine {
1143
1179
  mgr.logger.log(`[SYNC] Missing replay-safe fill key for order ${orderId} block ${blockNum}; deferring to open-orders sync`, 'warn');
1144
1180
  return { filledOrders: [], updatedOrders: [], partialFill: false, requiresOpenOrdersSync: true };
1145
1181
  }
1146
- const orderIdsToLock = new Set([orderId]);
1147
- 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;
1148
1189
  try {
1149
- 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]);
1150
1197
  try {
1151
- const appliedAccounting = await mgr.accountant.processFillAccounting(fillOp, fillKey, { persistenceMode });
1152
- if (!appliedAccounting) {
1153
- mgr.logger.log(`[SYNC] Replay detected for fill ${fillKey}; skipping duplicate order mutation`, 'debug');
1154
- return { filledOrders: [], updatedOrders: [], partialFill: false };
1155
- }
1156
- const assetAPrecision = mgr.assets?.assetA?.precision;
1157
- const assetBPrecision = mgr.assets?.assetB?.precision;
1158
- if (assetAPrecision === undefined || assetBPrecision === undefined) {
1159
- mgr.logger?.log?.('Error: manager.assets precision missing in syncFromFillHistory', 'error');
1160
- return { filledOrders: [], updatedOrders: [], partialFill: false };
1161
- }
1162
- const matchedGridOrder = this._findMatchingGridOrder(mgr, orderId);
1163
- if (!matchedGridOrder) {
1164
- mgr.logger.log(`[SYNC] Fill for order ${orderId} ignored: order not found in active grid.`, 'debug');
1165
- return { filledOrders: [], updatedOrders: [], partialFill: false };
1166
- }
1167
- const ctx = this._computeFillContext(mgr, matchedGridOrder, paysAssetId, paysAmountRaw);
1168
- mgr.logger.log(`[SYNC] Order ${orderId} (${ctx.orderType}) currentSize=${ctx.currentSize}, filledAmount=${ctx.filledAmount}`, 'debug');
1169
- let effectiveRawForSale = ctx.rawForSaleInt;
1170
- let chainConfirmsEmpty = false;
1171
- let chainRefetched = false;
1172
- if (ctx.driftSignal) {
1173
- try {
1174
- const fresh = await chainOrders.readSingleOrder(orderId, 3000);
1175
- if (fresh) {
1176
- const freshForSale = toFiniteNumber(fresh.for_sale, undefined);
1177
- if (Number.isFinite(freshForSale)) {
1178
- effectiveRawForSale = freshForSale;
1179
- chainRefetched = true;
1180
- mgr.logger.log(`[SYNC] Drift detected for ${orderId} (cached=${ctx.rawForSaleInt} < grid=${ctx.currentSizeIntFromGrid}); ` +
1181
- `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');
1182
1236
  }
1183
1237
  }
1184
- else {
1185
- chainConfirmsEmpty = true;
1186
- 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');
1187
1240
  }
1188
1241
  }
1189
- catch (refetchErr) {
1190
- mgr.logger.log(`[SYNC] Drift refetch for ${orderId} failed; falling back to cache: ${refetchErr?.message || refetchErr}`, 'warn');
1191
- }
1192
- }
1193
- const result = await this._computeFillTransitionResult(mgr, {
1194
- matchedGridOrder,
1195
- orderType: ctx.orderType,
1196
- precision: ctx.precision,
1197
- filledAmount: ctx.filledAmount,
1198
- filledAmountInt: (0, math_1.floatToBlockchainInt)(ctx.filledAmount, ctx.precision),
1199
- currentSizeIntFromGrid: ctx.currentSizeIntFromGrid,
1200
- rawForSaleInt: ctx.rawForSaleInt,
1201
- chainRefetched,
1202
- chainConfirmsEmpty,
1203
- effectiveRawForSale,
1204
- blockNum,
1205
- historyId,
1206
- isMaker
1207
- });
1208
- const filledOrders = [];
1209
- const updatedOrders = [];
1210
- if (result.isFull) {
1211
- if (result.isGhost) {
1212
- mgr.logger.log(`[SYNC] Ghost full fill for order ${orderId} (slot ${matchedGridOrder.id}): preserving orderId ${result.ghostOrderId} as PARTIAL to block duplicate CREATE.`, 'info');
1213
- const ghostOk = await mgr._updateOrder(result.ghostUpdate, 'handle-fill-ghost', { skipAccounting: false, fee: 0 });
1214
- if (ghostOk === false) {
1215
- mgr.logger.log(`[SYNC] Failed to apply ghost fill state for order ${orderId}`, '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');
1264
+ mgr.accountTotalsStale = true;
1216
1265
  }
1217
1266
  filledOrders.push(result.filledOrder);
1218
- return { filledOrders, updatedOrders, partialFill: false };
1219
1267
  }
1220
- mgr.logger.log(`[SYNC] Full fill for order ${orderId} (slot ${matchedGridOrder.id}).`, 'info');
1221
- const fullOk = await mgr._updateOrder(result.fullUpdate, 'handle-fill-full', { skipAccounting: false, fee: 0 });
1222
- if (fullOk === false) {
1223
- mgr.logger.log(`[SYNC] Failed to convert filled order ${orderId} to spread placeholder`, 'warn');
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);
1224
1277
  }
1225
- filledOrders.push(result.filledOrder);
1226
- 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 };
1227
1283
  }
1228
- else {
1229
- mgr.logger.log(`[SYNC] Partial fill for order ${orderId} (slot ${matchedGridOrder.id}): newSize=${result.newSize}`, 'info');
1230
- const partialOk = await mgr._updateOrder(result.partialUpdate, 'handle-fill-partial', { skipAccounting: false, fee: 0 });
1231
- if (partialOk === false) {
1232
- mgr.logger.log(`[SYNC] Failed to update partially filled order ${orderId}`, 'warn');
1233
- }
1234
- updatedOrders.push(result.partialUpdate);
1235
- filledOrders.push(result.filledOrder);
1236
- 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();
1237
1289
  }
1238
1290
  }
1239
1291
  finally {
1240
- await mgr.resumeFundRecalc();
1292
+ mgr.unlockOrders([...orderIdsToLock]);
1241
1293
  }
1242
1294
  }
1243
1295
  finally {
1244
- 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--;
1245
1300
  }
1246
1301
  }
1247
1302
  /**
@@ -1294,171 +1349,197 @@ class SyncEngine {
1294
1349
  }
1295
1350
  // Phase 2: Lock all unique order IDs once
1296
1351
  const allOrderIds = [...new Set(fillEntries.map(e => e.orderId))];
1297
- 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;
1298
1360
  try {
1299
- 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);
1300
1367
  try {
1301
- // Phase 3: Process accounting for each fill
1302
- const validEntries = [];
1303
- for (const entry of fillEntries) {
1304
- try {
1305
- const appliedAccounting = await mgr.accountant.processFillAccounting(entry.fillOp, entry.fillKey, { persistenceMode });
1306
- if (!appliedAccounting) {
1307
- 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');
1308
1383
  continue;
1309
1384
  }
1310
- validEntries.push(entry);
1311
1385
  }
1312
- catch (acctErr) {
1313
- mgr.logger.log(`[SYNC] Accounting error for fill ${entry.fillKey}: ${(0, errors_1.getErrorMessage)(acctErr)}`, 'error');
1314
- continue;
1386
+ if (validEntries.length === 0) {
1387
+ return { filledOrders: [], updatedOrders: [], partialFill: false, requiresOpenOrdersSync: anyRequiresSync };
1315
1388
  }
1316
- }
1317
- if (validEntries.length === 0) {
1318
- return { filledOrders: [], updatedOrders: [], partialFill: false, requiresOpenOrdersSync: anyRequiresSync };
1319
- }
1320
- // Phase 4: Build per-fill context + identify drift candidates
1321
- const entryContexts = [];
1322
- const driftOrderIds = new Set();
1323
- const assetAPrecision = mgr.assets?.assetA?.precision;
1324
- const assetBPrecision = mgr.assets?.assetB?.precision;
1325
- if (assetAPrecision === undefined || assetBPrecision === undefined) {
1326
- mgr.logger?.log?.('Error: manager.assets precision missing in syncFromFillHistoryBatch', 'error');
1327
- return { filledOrders: [], updatedOrders: [], partialFill: false, requiresOpenOrdersSync: anyRequiresSync };
1328
- }
1329
- for (const entry of validEntries) {
1330
- const { orderId, paysAmountRaw, paysAssetId } = entry;
1331
- const matchedGridOrder = this._findMatchingGridOrder(mgr, orderId);
1332
- if (!matchedGridOrder) {
1333
- mgr.logger.log(`[SYNC] Fill for order ${orderId} ignored: order not found in active grid.`, 'debug');
1334
- anyRequiresSync = true;
1335
- 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 };
1336
1397
  }
1337
- const ctx = this._computeFillContext(mgr, matchedGridOrder, paysAssetId, paysAmountRaw);
1338
- mgr.logger.log(`[SYNC] Order ${orderId} (${ctx.orderType}) currentSize=${ctx.currentSize}, filledAmount=${ctx.filledAmount}`, 'debug');
1339
- if (ctx.driftSignal) {
1340
- 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
+ });
1341
1420
  }
1342
- entryContexts.push({
1343
- entry, matchedGridOrder,
1344
- orderType: ctx.orderType,
1345
- precision: ctx.precision,
1346
- filledAmount: ctx.filledAmount,
1347
- filledAmountInt: (0, math_1.floatToBlockchainInt)(ctx.filledAmount, ctx.precision),
1348
- currentSizeIntFromGrid: ctx.currentSizeIntFromGrid,
1349
- rawForSaleInt: ctx.rawForSaleInt
1350
- });
1351
- }
1352
- // Phase 5: Batch drift refetch — one get_objects call for all order IDs
1353
- const refetchMap = new Map();
1354
- if (driftOrderIds.size > 0) {
1355
- try {
1356
- const batchResults = await chainOrders.batchReadOrders([...driftOrderIds], 3000);
1357
- for (const [orderId, freshOrder] of batchResults) {
1358
- if (freshOrder) {
1359
- const freshForSale = toFiniteNumber(freshOrder.for_sale, undefined);
1360
- if (Number.isFinite(freshForSale)) {
1361
- refetchMap.set(orderId, {
1362
- chainConfirmsEmpty: Math.round(freshForSale) <= 0,
1363
- chainRefetched: true,
1364
- effectiveRawForSale: freshForSale
1365
- });
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
+ }
1366
1443
  }
1367
1444
  else {
1368
1445
  refetchMap.set(orderId, {
1369
- chainConfirmsEmpty: false,
1446
+ chainConfirmsEmpty: true,
1370
1447
  chainRefetched: true,
1371
1448
  effectiveRawForSale: null
1372
1449
  });
1373
1450
  }
1374
1451
  }
1375
- else {
1376
- refetchMap.set(orderId, {
1377
- chainConfirmsEmpty: true,
1378
- chainRefetched: true,
1379
- effectiveRawForSale: null
1380
- });
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
+ }
1381
1459
  }
1382
1460
  }
1383
- for (const [orderId, { chainRefetched, chainConfirmsEmpty, effectiveRawForSale }] of refetchMap) {
1384
- if (chainRefetched && effectiveRawForSale != null) {
1385
- mgr.logger.log(`[SYNC] Drift detected for ${orderId}; batch-refetched for_sale=${effectiveRawForSale}`, 'warn');
1386
- }
1387
- else if (chainConfirmsEmpty) {
1388
- mgr.logger.log(`[SYNC] Drift refetch for ${orderId} returned null; chain confirms empty`, 'info');
1389
- }
1461
+ catch (refetchErr) {
1462
+ mgr.logger.log(`[SYNC] Batch drift refetch failed for ${driftOrderIds.size} orders; falling back to cache: ${refetchErr?.message || refetchErr}`, 'warn');
1390
1463
  }
1391
1464
  }
1392
- catch (refetchErr) {
1393
- mgr.logger.log(`[SYNC] Batch drift refetch failed for ${driftOrderIds.size} orders; falling back to cache: ${refetchErr?.message || refetchErr}`, 'warn');
1394
- }
1395
- }
1396
- // Phase 6: Compute state transitions and collect grid updates
1397
- const gridUpdates = [];
1398
- const filledOrders = [];
1399
- const updatedOrders = [];
1400
- let anyPartialFill = false;
1401
- for (const ctx of entryContexts) {
1402
- const { entry, matchedGridOrder, orderType, precision, filledAmount, filledAmountInt, currentSizeIntFromGrid, rawForSaleInt } = ctx;
1403
- const { orderId, blockNum, historyId, isMaker } = entry;
1404
- const refetchInfo = refetchMap.get(orderId);
1405
- const chainRefetched = refetchInfo?.chainRefetched || false;
1406
- const chainConfirmsEmpty = refetchInfo?.chainConfirmsEmpty || false;
1407
- const effectiveRawForSale = refetchInfo?.effectiveRawForSale != null ? refetchInfo.effectiveRawForSale : rawForSaleInt;
1408
- const result = await this._computeFillTransitionResult(mgr, {
1409
- matchedGridOrder,
1410
- orderType,
1411
- precision,
1412
- filledAmount,
1413
- filledAmountInt,
1414
- currentSizeIntFromGrid,
1415
- rawForSaleInt,
1416
- chainRefetched,
1417
- chainConfirmsEmpty,
1418
- effectiveRawForSale,
1419
- blockNum,
1420
- historyId,
1421
- isMaker
1422
- });
1423
- if (result.isFull) {
1424
- if (result.isGhost) {
1425
- mgr.logger.log(`[SYNC] Ghost full fill for order ${orderId} (slot ${matchedGridOrder.id}): ` +
1426
- `preserving orderId ${result.ghostOrderId} as PARTIAL to block duplicate CREATE.`, 'info');
1427
- gridUpdates.push({ id: matchedGridOrder.id, ...result.ghostUpdate, context: 'handle-fill-ghost' });
1428
- }
1429
- 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) {
1430
1493
  mgr.logger.log(`[SYNC] Full fill for order ${orderId} (slot ${matchedGridOrder.id}).`, 'info');
1431
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;
1432
1503
  }
1433
- filledOrders.push(result.filledOrder);
1434
1504
  }
1435
- else {
1436
- mgr.logger.log(`[SYNC] Partial fill for order ${orderId} (slot ${matchedGridOrder.id}): newSize=${result.newSize}`, 'info');
1437
- gridUpdates.push({ id: matchedGridOrder.id, ...result.partialUpdate, context: 'handle-fill-partial' });
1438
- updatedOrders.push(result.partialUpdate);
1439
- filledOrders.push(result.filledOrder);
1440
- 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
+ }
1441
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 };
1442
1526
  }
1443
- // Phase 7: Single grid batch update — acquires _gridLock once
1444
- if (gridUpdates.length > 0) {
1445
- const updateObjects = gridUpdates.map(u => {
1446
- const { context, ...orderData } = u;
1447
- return orderData;
1448
- });
1449
- const batchOk = await mgr.applyGridUpdateBatch(updateObjects, 'handle-fill-batch', { skipAccounting: false, fee: 0 });
1450
- if (batchOk === false) {
1451
- mgr.logger.log('[SYNC] Batch grid update failed for some fills; next sync cycle will reconcile', 'warn');
1452
- }
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();
1453
1532
  }
1454
- return { filledOrders, updatedOrders, partialFill: anyPartialFill, requiresOpenOrdersSync: anyRequiresSync };
1455
1533
  }
1456
1534
  finally {
1457
- await mgr.resumeFundRecalc();
1535
+ mgr.unlockOrders(allOrderIds);
1458
1536
  }
1459
1537
  }
1460
1538
  finally {
1461
- 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--;
1462
1543
  }
1463
1544
  }
1464
1545
  /**
@@ -1549,14 +1630,21 @@ class SyncEngine {
1549
1630
  }
1550
1631
  }
1551
1632
  const newState = isPartialPlacement ? constants_1.ORDER_STATES.PARTIAL : constants_1.ORDER_STATES.ACTIVE;
1552
- 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)
1553
1634
  ? expectedType
1554
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
+ }
1555
1643
  const updatedOrder = {
1556
1644
  ...gridOrder,
1557
1645
  type: normalizedExpectedType || gridOrder.type,
1558
1646
  state: newState,
1559
- orderId: chainOrderId
1647
+ orderId: chainOrderId,
1560
1648
  };
1561
1649
  // Restore btsFeeState from raw chain order data if provided.
1562
1650
  // After a grid reset, in-memory orders have no fee state but the