dexbot 1.2.5 → 1.2.7

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 (70) hide show
  1. package/README.md +30 -27
  2. package/dist/dexbot.d.ts +22 -22
  3. package/dist/dexbot.js +29 -29
  4. package/dist/dexbot.js.map +1 -1
  5. package/dist/modules/account_orders.d.ts +8 -1
  6. package/dist/modules/account_orders.d.ts.map +1 -1
  7. package/dist/modules/account_orders.js +24 -1
  8. package/dist/modules/account_orders.js.map +1 -1
  9. package/dist/modules/chain_orders.d.ts +16 -0
  10. package/dist/modules/chain_orders.d.ts.map +1 -1
  11. package/dist/modules/chain_orders.js +41 -0
  12. package/dist/modules/chain_orders.js.map +1 -1
  13. package/dist/modules/constants.d.ts +0 -3
  14. package/dist/modules/constants.d.ts.map +1 -1
  15. package/dist/modules/constants.js +0 -14
  16. package/dist/modules/constants.js.map +1 -1
  17. package/dist/modules/dexbot_class.d.ts +10 -2
  18. package/dist/modules/dexbot_class.d.ts.map +1 -1
  19. package/dist/modules/dexbot_class.js +176 -57
  20. package/dist/modules/dexbot_class.js.map +1 -1
  21. package/dist/modules/dexbot_fill_runtime.d.ts +2 -2
  22. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  23. package/dist/modules/dexbot_fill_runtime.js +3 -3
  24. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  25. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  26. package/dist/modules/dexbot_maintenance_runtime.js +8 -9
  27. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  28. package/dist/modules/node_manager.d.ts.map +1 -1
  29. package/dist/modules/node_manager.js +7 -0
  30. package/dist/modules/node_manager.js.map +1 -1
  31. package/dist/modules/order/accounting.d.ts +2 -18
  32. package/dist/modules/order/accounting.d.ts.map +1 -1
  33. package/dist/modules/order/accounting.js +22 -49
  34. package/dist/modules/order/accounting.js.map +1 -1
  35. package/dist/modules/order/async_lock.d.ts +10 -0
  36. package/dist/modules/order/async_lock.d.ts.map +1 -1
  37. package/dist/modules/order/async_lock.js +60 -5
  38. package/dist/modules/order/async_lock.js.map +1 -1
  39. package/dist/modules/order/grid.js +2 -2
  40. package/dist/modules/order/grid.js.map +1 -1
  41. package/dist/modules/order/manager.d.ts +8 -1
  42. package/dist/modules/order/manager.d.ts.map +1 -1
  43. package/dist/modules/order/manager.js +28 -14
  44. package/dist/modules/order/manager.js.map +1 -1
  45. package/dist/modules/order/strategy.d.ts +2 -49
  46. package/dist/modules/order/strategy.d.ts.map +1 -1
  47. package/dist/modules/order/strategy.js +4 -132
  48. package/dist/modules/order/strategy.js.map +1 -1
  49. package/dist/modules/order/sync_engine.d.ts +53 -6
  50. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  51. package/dist/modules/order/sync_engine.js +384 -204
  52. package/dist/modules/order/sync_engine.js.map +1 -1
  53. package/dist/modules/order/utils/math.d.ts.map +1 -1
  54. package/dist/modules/order/utils/math.js +8 -3
  55. package/dist/modules/order/utils/math.js.map +1 -1
  56. package/dist/modules/order/utils/order.js +1 -1
  57. package/dist/modules/order/utils/order.js.map +1 -1
  58. package/dist/modules/order/utils/system.d.ts +1 -1
  59. package/dist/modules/order/utils/system.d.ts.map +1 -1
  60. package/dist/modules/order/utils/system.js +3 -2
  61. package/dist/modules/order/utils/system.js.map +1 -1
  62. package/dist/modules/runtime_settings.d.ts +3 -0
  63. package/dist/modules/runtime_settings.d.ts.map +1 -1
  64. package/dist/modules/runtime_settings.js +34 -1
  65. package/dist/modules/runtime_settings.js.map +1 -1
  66. package/dist/modules/types.d.ts +29 -4
  67. package/dist/modules/types.d.ts.map +1 -1
  68. package/package.json +1 -1
  69. package/scripts/README.md +9 -9
  70. package/scripts/reset-settings.sh +1 -1
@@ -40,25 +40,30 @@
40
40
  * Pass 1: Match grid orders to chain (known grid → chain, includes orphan cleanup)
41
41
  * Pass 2: Add missing chain orders (unknown chain → grid)
42
42
  *
43
- * FILL PROCESSING (1 method)
44
- * 5. syncFromFillHistory(fill) - Process fill event synchronously
43
+ * FILL PROCESSING (2 methods)
44
+ * 5. syncFromFillHistory(fill) - Process single fill event synchronously
45
45
  * Updates grid order state based on fill data
46
46
  * Updates fund state and accounting
47
47
  * Handles both maker and taker fills
48
48
  *
49
+ * 6. syncFromFillHistoryBatch(fills) - Process multiple fill events in batch
50
+ * Acquires _gridLock once for all fills in block group
51
+ * Batches drift refetch into one get_objects RPC call
52
+ * Applies all grid mutations in a single applyGridUpdateBatch
53
+ *
49
54
  * FULL SYNCHRONIZATION (1 method - async)
50
- * 6. synchronizeWithChain(chainData, source) - Full sync (fetch + sync) (async)
55
+ * 7. synchronizeWithChain(chainData, source) - Full sync (fetch + sync) (async)
51
56
  * Fetches fresh account balances
52
57
  * Calls syncFromOpenOrders() with chain data
53
58
  * Source: event type that triggered sync (fill, poll, broadcast, etc.)
54
59
  *
55
60
  * ACCOUNT STATE (2 methods - async)
56
- * 7. fetchAccountBalancesAndSetTotals(accountId) - Fetch account totals (async)
61
+ * 8. fetchAccountBalancesAndSetTotals(accountId) - Fetch account totals (async)
57
62
  * Retrieves BUY/SELL totals and free balances from blockchain
58
63
  * Sets manager.accountTotals
59
64
  * Triggers fund recalculation
60
65
  *
61
- * 8. initializeAssets() - Initialize asset metadata (async)
66
+ * 9. initializeAssets() - Initialize asset metadata (async)
62
67
  * Fetches asset precision and other metadata
63
68
  * Sets manager.assets
64
69
  * Called once at bot startup
@@ -91,28 +96,7 @@ const { blockchainToFloat, floatToBlockchainInt, hasValidAccountTotals, calculat
91
96
  const { parseChainOrder, findMatchingGridOrderByOpenOrder, applyChainSizeToGridOrder, convertToSpreadPlaceholder, virtualizeOrder, buildFillKey, isOrderPlaced, isOrderOnChain, hasOnChainId, isOrderVirtual, isPhantomOrder } = require('./utils/order');
92
97
  const { resolveProcessedFillPersistenceMode } = require('./processed_fill_store');
93
98
  const { lookupAsset } = require('./utils/system');
94
- /**
95
- * Compare two raw chain order objects for on-chain equivalence.
96
- * @param {Object|null} a - First chain order
97
- * @param {Object|null} b - Second chain order
98
- * @returns {boolean} True if orders are equivalent
99
- */
100
- function hasEquivalentRawOnChainOrder(a, b) {
101
- if (!a && !b)
102
- return true;
103
- if (!a || !b)
104
- return false;
105
- const aBase = a.sell_price?.base || {};
106
- const bBase = b.sell_price?.base || {};
107
- const aQuote = a.sell_price?.quote || {};
108
- const bQuote = b.sell_price?.quote || {};
109
- return String(a.id ?? '') === String(b.id ?? '') &&
110
- String(a.for_sale ?? '') === String(b.for_sale ?? '') &&
111
- String(aBase.amount ?? '') === String(bBase.amount ?? '') &&
112
- String(aBase.asset_id ?? '') === String(bBase.asset_id ?? '') &&
113
- String(aQuote.amount ?? '') === String(bQuote.amount ?? '') &&
114
- String(aQuote.asset_id ?? '') === String(bQuote.asset_id ?? '');
115
- }
99
+ const chainOrders = require('../chain_orders');
116
100
  function describeNearestAdoptionCandidates(mgr, chainOrder, precision, calcTolerance, matchedGridOrderIds = null) {
117
101
  if (!mgr?.orders || !chainOrder || typeof precision !== 'number')
118
102
  return 'candidate diagnostics unavailable';
@@ -181,9 +165,6 @@ function describeNearestAdoptionCandidates(mgr, chainOrder, precision, calcToler
181
165
  ` reason=${reasons.join('|') || 'unknown'}`;
182
166
  }).join('; ');
183
167
  }
184
- function _getDriftToleranceMultiplier(mgr) {
185
- return mgr?.config?.gridLimits?.PRICE_DRIFT_TOLERANCE_MULTIPLIER;
186
- }
187
168
  /**
188
169
  * Find the closest same-side (or spread) candidate slot for a chain order and
189
170
  * return a "price-drift-orphan" tag if the slot's price diff is larger than the
@@ -235,7 +216,8 @@ function computeOutOfToleranceDriftTag(mgr, chainOrder, calcToleranceFn) {
235
216
  const tolerance = (calcToleranceFn ? (calcToleranceFn(slotPrice, effectiveSize, orderType) || 0) : 0);
236
217
  if (priceDiff <= tolerance)
237
218
  continue;
238
- const driftBudget = tolerance * _getDriftToleranceMultiplier(mgr);
219
+ const driftMultiplier = mgr?.config?.gridLimits?.PRICE_DRIFT_TOLERANCE_MULTIPLIER;
220
+ const driftBudget = tolerance * driftMultiplier;
239
221
  if (driftBudget > 0 && priceDiff > driftBudget)
240
222
  continue;
241
223
  if (!bestDrift || priceDiff < bestDrift.priceDiff) {
@@ -963,6 +945,121 @@ class SyncEngine {
963
945
  }
964
946
  return { filledOrders, updatedOrders, ordersNeedingCorrection, unmatchedChainOrders };
965
947
  }
948
+ // ------------------------------------------------------------------
949
+ // Shared fill-processing helpers (used by syncFromFillHistory and
950
+ // syncFromFillHistoryBatch)
951
+ // ------------------------------------------------------------------
952
+ _findMatchingGridOrder(mgr, orderId) {
953
+ for (const gridOrder of mgr.orders.values()) {
954
+ if (gridOrder.orderId === orderId && (gridOrder.state === ORDER_STATES.ACTIVE || gridOrder.state === ORDER_STATES.PARTIAL)) {
955
+ return gridOrder;
956
+ }
957
+ }
958
+ return null;
959
+ }
960
+ _computeFillContext(mgr, matchedGridOrder, paysAssetId, paysAmountRaw) {
961
+ const orderType = matchedGridOrder.type;
962
+ const currentSize = toFiniteNumber(matchedGridOrder.size);
963
+ const precision = (orderType === ORDER_TYPES.SELL) ? mgr.assets.assetA.precision : mgr.assets.assetB.precision;
964
+ let filledAmount = 0;
965
+ if (orderType === ORDER_TYPES.SELL) {
966
+ if (paysAssetId === mgr.assets.assetA.id)
967
+ filledAmount = blockchainToFloat(paysAmountRaw, precision, true);
968
+ }
969
+ else {
970
+ if (paysAssetId === mgr.assets.assetB.id)
971
+ filledAmount = blockchainToFloat(paysAmountRaw, precision, true);
972
+ }
973
+ const currentSizeIntFromGrid = floatToBlockchainInt(currentSize, precision);
974
+ const rawForSaleInt = toFiniteNumber(matchedGridOrder?.rawOnChain?.for_sale, null);
975
+ const driftSignal = Number.isFinite(rawForSaleInt) && Math.round(rawForSaleInt) < currentSizeIntFromGrid;
976
+ return { orderType, currentSize, precision, filledAmount, currentSizeIntFromGrid, rawForSaleInt, driftSignal };
977
+ }
978
+ async _computeFillTransitionResult(mgr, params) {
979
+ const { matchedGridOrder, orderType, precision, filledAmount, filledAmountInt, currentSizeIntFromGrid, rawForSaleInt, chainRefetched, chainConfirmsEmpty, effectiveRawForSale, blockNum, historyId, isMaker } = params;
980
+ let resolvedChainConfirmsEmpty = chainConfirmsEmpty;
981
+ if (chainRefetched && Number.isFinite(effectiveRawForSale) && Math.round(effectiveRawForSale) <= 0) {
982
+ resolvedChainConfirmsEmpty = true;
983
+ }
984
+ const currentSizeInt = Number.isFinite(effectiveRawForSale)
985
+ ? Math.max(0, Math.round(effectiveRawForSale))
986
+ : currentSizeIntFromGrid;
987
+ const newSizeInt = Math.max(0, currentSizeInt - filledAmountInt);
988
+ const newSize = blockchainToFloat(newSizeInt, precision, true);
989
+ if (Number.isFinite(effectiveRawForSale) && currentSizeInt !== currentSizeIntFromGrid) {
990
+ mgr.logger.log(`[SYNC] Using rawOnChain.for_sale baseline for ${matchedGridOrder.orderId}: raw=${currentSizeInt}, grid=${currentSizeIntFromGrid}` +
991
+ (chainRefetched ? ' (refetched)' : ''), 'debug');
992
+ }
993
+ const gridAlsoEmpty = currentSizeIntFromGrid <= 0;
994
+ let isEffectivelyFull = resolvedChainConfirmsEmpty || gridAlsoEmpty;
995
+ let ghostOrderId;
996
+ if (!isEffectivelyFull) {
997
+ const otherPrecision = (orderType === ORDER_TYPES.SELL) ? mgr.assets.assetB.precision : mgr.assets.assetA.precision;
998
+ const price = matchedGridOrder.price;
999
+ const otherSize = (orderType === ORDER_TYPES.SELL) ? newSize * price : newSize / price;
1000
+ if (floatToBlockchainInt(otherSize, otherPrecision) <= 0) {
1001
+ mgr.logger.log(`[SYNC] Order ${matchedGridOrder.orderId} (slot ${matchedGridOrder.id}) other-side (${otherSize}) rounds to 0. Treating as full fill to trigger rotation.`, 'info');
1002
+ isEffectivelyFull = true;
1003
+ ghostOrderId = matchedGridOrder.orderId;
1004
+ }
1005
+ }
1006
+ let fullUpdate;
1007
+ let ghostUpdate;
1008
+ let partialUpdate;
1009
+ let filledOrderResult;
1010
+ if (isEffectivelyFull) {
1011
+ if (ghostOrderId) {
1012
+ ghostUpdate = {
1013
+ ...matchedGridOrder,
1014
+ size: 0,
1015
+ state: ORDER_STATES.PARTIAL,
1016
+ orderId: ghostOrderId,
1017
+ };
1018
+ }
1019
+ else {
1020
+ fullUpdate = convertToSpreadPlaceholder(matchedGridOrder);
1021
+ }
1022
+ filledOrderResult = { ...matchedGridOrder, blockNum, historyId, isMaker };
1023
+ }
1024
+ else {
1025
+ filledOrderResult = {
1026
+ ...matchedGridOrder,
1027
+ size: filledAmount,
1028
+ isPartial: true,
1029
+ blockNum,
1030
+ historyId,
1031
+ isMaker
1032
+ };
1033
+ const { btsFeeState, ...matchedWithoutDeferredFee } = matchedGridOrder;
1034
+ let updatedOrder = { ...matchedWithoutDeferredFee, state: ORDER_STATES.PARTIAL };
1035
+ if (updatedOrder.rawOnChain && updatedOrder.rawOnChain.for_sale !== undefined) {
1036
+ const baselineForSale = (chainRefetched && Number.isFinite(effectiveRawForSale))
1037
+ ? effectiveRawForSale
1038
+ : toFiniteNumber(updatedOrder.rawOnChain.for_sale);
1039
+ const nextForSaleInt = Math.max(0, Math.round(baselineForSale) - filledAmountInt);
1040
+ updatedOrder.rawOnChain = {
1041
+ ...updatedOrder.rawOnChain,
1042
+ for_sale: String(nextForSaleInt),
1043
+ fetchedAt: Date.now()
1044
+ };
1045
+ }
1046
+ const nextOrder = await applyChainSizeToGridOrder(mgr, updatedOrder, newSize);
1047
+ if (nextOrder) {
1048
+ updatedOrder = { ...updatedOrder, ...nextOrder };
1049
+ }
1050
+ partialUpdate = updatedOrder;
1051
+ }
1052
+ return {
1053
+ isFull: isEffectivelyFull,
1054
+ isGhost: !!ghostOrderId,
1055
+ ghostOrderId,
1056
+ filledOrder: filledOrderResult,
1057
+ fullUpdate,
1058
+ ghostUpdate,
1059
+ partialUpdate,
1060
+ newSize
1061
+ };
1062
+ }
966
1063
  /**
967
1064
  * Process one incremental fill-history event.
968
1065
  * @param {Object} fill - Fill history event object
@@ -981,7 +1078,7 @@ class SyncEngine {
981
1078
  if (fillOp.is_maker === undefined) {
982
1079
  mgr.logger.log(`[SYNC] is_maker flag missing from fill data for order ${orderId}; defaulting to maker`, 'warn');
983
1080
  }
984
- const isMaker = fillOp.is_maker !== false; // Default missing flag to maker for consistency with accounting
1081
+ const isMaker = fillOp.is_maker !== false;
985
1082
  const fillKey = buildFillKey({ orderId, blockNum, historyId });
986
1083
  const paysAmountRaw = toFiniteNumber(fillOp.pays?.amount);
987
1084
  const paysAssetId = fillOp.pays ? fillOp.pays.asset_id : null;
@@ -992,231 +1089,96 @@ class SyncEngine {
992
1089
  mgr.logger.log(`[SYNC] Missing replay-safe fill key for order ${orderId} block ${blockNum}; deferring to open-orders sync`, 'warn');
993
1090
  return { filledOrders: [], updatedOrders: [], partialFill: false, requiresOpenOrdersSync: true };
994
1091
  }
995
- // Lock the order to prevent concurrent modifications from createOrder/cancelOrder/sync
996
- // TOCTOU: lock before processFillAccounting so _buildBtsDeferredRefundAdjustment
997
- // reads order state (btsFeeState) under the same lock as the state update below.
998
1092
  const orderIdsToLock = new Set([orderId]);
999
1093
  mgr.lockOrders([...orderIdsToLock]);
1000
1094
  try {
1001
1095
  mgr.pauseFundRecalc();
1002
1096
  try {
1003
- // Optimistically update account totals to reflect the fill.
1004
- // Under lock — btsFeeState on mgr.orders is stable for _buildBtsDeferredRefundAdjustment.
1005
1097
  const appliedAccounting = await mgr.accountant.processFillAccounting(fillOp, fillKey, { persistenceMode });
1006
1098
  if (!appliedAccounting) {
1007
1099
  mgr.logger.log(`[SYNC] Replay detected for fill ${fillKey}; skipping duplicate order mutation`, 'debug');
1008
1100
  return { filledOrders: [], updatedOrders: [], partialFill: false };
1009
1101
  }
1010
- const paysAmount = paysAmountRaw;
1011
1102
  const assetAPrecision = mgr.assets?.assetA?.precision;
1012
1103
  const assetBPrecision = mgr.assets?.assetB?.precision;
1013
1104
  if (assetAPrecision === undefined || assetBPrecision === undefined) {
1014
1105
  mgr.logger?.log?.('Error: manager.assets precision missing in syncFromFillHistory', 'error');
1015
1106
  return { filledOrders: [], updatedOrders: [], partialFill: false };
1016
1107
  }
1017
- let matchedGridOrder = null;
1018
- for (const gridOrder of mgr.orders.values()) {
1019
- if (gridOrder.orderId === orderId && (gridOrder.state === ORDER_STATES.ACTIVE || gridOrder.state === ORDER_STATES.PARTIAL)) {
1020
- matchedGridOrder = gridOrder;
1021
- break;
1022
- }
1023
- }
1108
+ const matchedGridOrder = this._findMatchingGridOrder(mgr, orderId);
1024
1109
  if (!matchedGridOrder) {
1025
1110
  mgr.logger.log(`[SYNC] Fill for order ${orderId} ignored: order not found in active grid.`, 'debug');
1026
1111
  return { filledOrders: [], updatedOrders: [], partialFill: false };
1027
1112
  }
1028
- const orderType = matchedGridOrder.type;
1029
- const currentSize = toFiniteNumber(matchedGridOrder.size);
1030
- // CRITICAL: Sells are sized in AssetA, Buys are sized in AssetB
1031
- const precision = (orderType === ORDER_TYPES.SELL) ? assetAPrecision : assetBPrecision;
1032
- let filledAmount = 0;
1033
- if (orderType === ORDER_TYPES.SELL) {
1034
- if (paysAssetId === mgr.assets.assetA.id)
1035
- filledAmount = blockchainToFloat(paysAmount, precision, true);
1036
- }
1037
- else {
1038
- if (paysAssetId === mgr.assets.assetB.id)
1039
- filledAmount = blockchainToFloat(paysAmount, precision, true);
1040
- }
1041
- mgr.logger.log(`[SYNC] Order ${orderId} (${orderType}) currentSize=${currentSize}, filledAmount=${filledAmount}`, 'debug');
1042
- const currentSizeIntFromGrid = floatToBlockchainInt(currentSize, precision);
1043
- const rawForSaleInt = toFiniteNumber(matchedGridOrder?.rawOnChain?.for_sale, null);
1044
- let effectiveRawForSale = rawForSaleInt;
1113
+ const ctx = this._computeFillContext(mgr, matchedGridOrder, paysAssetId, paysAmountRaw);
1114
+ mgr.logger.log(`[SYNC] Order ${orderId} (${ctx.orderType}) currentSize=${ctx.currentSize}, filledAmount=${ctx.filledAmount}`, 'debug');
1115
+ let effectiveRawForSale = ctx.rawForSaleInt;
1045
1116
  let chainConfirmsEmpty = false;
1046
1117
  let chainRefetched = false;
1047
- // -----------------------------------------------------------------
1048
- // Drift-only refetch: the cache is self-correcting across
1049
- // sequential websocket fills, but if a fill was ever missed
1050
- // (reconnect, restart, reorg, external activity), the cache and
1051
- // the grid can disagree.
1052
- // The only signal we can detect from the in-memory state is a
1053
- // consistent drift: cached for_sale < grid size. Chain `for_sale`
1054
- // can only decrease, so a smaller cached value is an obvious
1055
- // inconsistency that warrants a targeted refetch.
1056
- //
1057
- // We do NOT refetch on a time-based TTL. A stale-but-consistent
1058
- // cache is still correct; a fresh-but-wrong cache is the actual
1059
- // bug we're guarding against, and the drift signal catches that.
1060
- // The refetch is best-effort: a network error falls back to the
1061
- // cache with a warning, and the open-orders sync is the ultimate
1062
- // recovery channel.
1063
- // -----------------------------------------------------------------
1064
- const driftSignal = Number.isFinite(rawForSaleInt)
1065
- && Math.round(rawForSaleInt) < currentSizeIntFromGrid;
1066
- if (driftSignal) {
1118
+ if (ctx.driftSignal) {
1067
1119
  try {
1068
- const { readSingleOrder } = require('../chain_orders');
1069
- const fresh = await readSingleOrder(orderId, 3000);
1120
+ const fresh = await chainOrders.readSingleOrder(orderId, 3000);
1070
1121
  if (fresh) {
1071
1122
  const freshForSale = toFiniteNumber(fresh.for_sale, null);
1072
1123
  if (Number.isFinite(freshForSale)) {
1073
1124
  effectiveRawForSale = freshForSale;
1074
1125
  chainRefetched = true;
1075
- mgr.logger.log(`[SYNC] Drift detected for ${orderId} (cached=${rawForSaleInt} < grid=${currentSizeIntFromGrid}); ` +
1126
+ mgr.logger.log(`[SYNC] Drift detected for ${orderId} (cached=${ctx.rawForSaleInt} < grid=${ctx.currentSizeIntFromGrid}); ` +
1076
1127
  `refetched for_sale=${freshForSale}`, 'warn');
1077
1128
  }
1078
1129
  }
1079
1130
  else {
1080
- // Chain returned null — the order is genuinely absent.
1081
- // This is the authoritative "chain confirms empty"
1082
- // signal (vs. an RPC failure which would have thrown).
1083
1131
  chainConfirmsEmpty = true;
1084
1132
  mgr.logger.log(`[SYNC] Drift refetch for ${orderId} returned null; chain confirms empty`, 'info');
1085
1133
  }
1086
1134
  }
1087
1135
  catch (refetchErr) {
1088
- mgr.logger.log(`[SYNC] Drift refetch for ${orderId} failed; falling back to cache: ` +
1089
- `${refetchErr?.message || refetchErr}`, 'warn');
1090
- }
1091
- }
1092
- // Chain-confirmed-empty gate: only true when the drift refetch
1093
- // returned null (i.e., the chain is definitively saying the order
1094
- // is gone). We deliberately do not trust a cached 0 from a
1095
- // never-refetched source — that would misclassify a 0.003 XRP
1096
- // residual as a full fill.
1097
- if (chainRefetched
1098
- && Number.isFinite(effectiveRawForSale)
1099
- && Math.round(effectiveRawForSale) <= 0) {
1100
- chainConfirmsEmpty = true;
1101
- }
1102
- const currentSizeInt = Number.isFinite(effectiveRawForSale)
1103
- ? Math.max(0, Math.round(effectiveRawForSale))
1104
- : currentSizeIntFromGrid;
1105
- const filledAmountInt = floatToBlockchainInt(filledAmount, precision);
1106
- const newSizeInt = Math.max(0, currentSizeInt - filledAmountInt);
1107
- const newSize = blockchainToFloat(newSizeInt, precision, true); // needed for partial fills
1108
- if (Number.isFinite(effectiveRawForSale) && currentSizeInt !== currentSizeIntFromGrid) {
1109
- mgr.logger.log(`[SYNC] Using rawOnChain.for_sale baseline for ${orderId}: raw=${currentSizeInt}, grid=${currentSizeIntFromGrid}` +
1110
- (chainRefetched ? ' (refetched)' : ''), 'debug');
1111
- }
1112
- // CRITICAL (v0.5.1 Robustness + chain-confirmed-empty gate):
1113
- // An order is "effectively" full if EITHER:
1114
- // 1. The chain has confirmed the order is gone (the drift
1115
- // refetch above returned null), OR
1116
- // 2. The grid itself is at 0 (no drift between grid and chain), OR
1117
- // 3. The OTHER side rounds to 0 (the chain will close the order
1118
- // regardless of the residual on the size side — see
1119
- // test_ghost_order_fix.ts for the intended behaviour).
1120
- //
1121
- // We deliberately do NOT use `newSizeInt <= 0` as a fast-path
1122
- // full-fill signal: with a stale cached rawOnChain, that arithmetic
1123
- // can be wrong and misclassify a partial fill as full. The
1124
- // 30-satoshi residual that triggered the bot-stale incident is the
1125
- // motivating case.
1126
- const gridAlsoEmpty = currentSizeIntFromGrid <= 0;
1127
- let isEffectivelyFull = chainConfirmsEmpty || gridAlsoEmpty;
1128
- let ghostOrderId;
1129
- if (!isEffectivelyFull) {
1130
- // Check the "other" side's precision. If the remaining amount to receive/pay
1131
- // rounds to 0 on the blockchain, the order will be closed regardless of newSizeInt.
1132
- const otherPrecision = (orderType === ORDER_TYPES.SELL) ? assetBPrecision : assetAPrecision;
1133
- const price = matchedGridOrder.price;
1134
- const otherSize = (orderType === ORDER_TYPES.SELL) ? newSize * price : newSize / price;
1135
- if (floatToBlockchainInt(otherSize, otherPrecision) <= 0) {
1136
- mgr.logger.log(`[SYNC] Order ${orderId} (slot ${matchedGridOrder.id}) other-side (${otherSize}) rounds to 0. Treating as full fill to trigger rotation.`, 'info');
1137
- isEffectivelyFull = true;
1138
- ghostOrderId = matchedGridOrder.orderId;
1136
+ mgr.logger.log(`[SYNC] Drift refetch for ${orderId} failed; falling back to cache: ${refetchErr?.message || refetchErr}`, 'warn');
1139
1137
  }
1140
1138
  }
1139
+ const result = await this._computeFillTransitionResult(mgr, {
1140
+ matchedGridOrder,
1141
+ orderType: ctx.orderType,
1142
+ precision: ctx.precision,
1143
+ filledAmount: ctx.filledAmount,
1144
+ filledAmountInt: floatToBlockchainInt(ctx.filledAmount, ctx.precision),
1145
+ currentSizeIntFromGrid: ctx.currentSizeIntFromGrid,
1146
+ rawForSaleInt: ctx.rawForSaleInt,
1147
+ chainRefetched,
1148
+ chainConfirmsEmpty,
1149
+ effectiveRawForSale,
1150
+ blockNum,
1151
+ historyId,
1152
+ isMaker
1153
+ });
1141
1154
  const filledOrders = [];
1142
1155
  const updatedOrders = [];
1143
- if (isEffectivelyFull) {
1144
- const filledOrder = {
1145
- ...matchedGridOrder,
1146
- blockNum: blockNum,
1147
- historyId: historyId,
1148
- isMaker: isMaker // Preserve maker/taker flag for accurate fee calculation
1149
- };
1150
- if (ghostOrderId) {
1151
- // Ghost order: the chain order still exists (other-side
1152
- // rounding to 0) but the chain hasn't closed it yet. Keep
1153
- // the slot PARTIAL with its orderId so guards
1154
- // (validateCreateTargetSlots, reconcileGrid) see the slot
1155
- // as occupied and will NOT generate a duplicate CREATE.
1156
- // The best-effort cancel in dexbot_class cleans up the
1157
- // chain order; the next sync cycle virtualizes the
1158
- // confirmed-free slot.
1159
- mgr.logger.log(`[SYNC] Ghost full fill for order ${orderId} (slot ${matchedGridOrder.id}): ` +
1160
- `preserving orderId ${ghostOrderId} as PARTIAL to block duplicate CREATE.`, 'info');
1161
- const ghostOrder = {
1162
- ...matchedGridOrder,
1163
- size: 0,
1164
- state: ORDER_STATES.PARTIAL,
1165
- orderId: ghostOrderId,
1166
- };
1167
- const ghostOk = await mgr._updateOrder(ghostOrder, 'handle-fill-ghost', { skipAccounting: false, fee: 0 });
1156
+ if (result.isFull) {
1157
+ if (result.isGhost) {
1158
+ mgr.logger.log(`[SYNC] Ghost full fill for order ${orderId} (slot ${matchedGridOrder.id}): preserving orderId ${result.ghostOrderId} as PARTIAL to block duplicate CREATE.`, 'info');
1159
+ const ghostOk = await mgr._updateOrder(result.ghostUpdate, 'handle-fill-ghost', { skipAccounting: false, fee: 0 });
1168
1160
  if (ghostOk === false) {
1169
1161
  mgr.logger.log(`[SYNC] Failed to apply ghost fill state for order ${orderId}`, 'warn');
1170
1162
  }
1171
- filledOrders.push(filledOrder);
1172
- return { filledOrders, updatedOrders, partialFill: false, ghostOrderId };
1163
+ filledOrders.push(result.filledOrder);
1164
+ return { filledOrders, updatedOrders, partialFill: false, ghostOrderId: result.ghostOrderId };
1173
1165
  }
1174
1166
  mgr.logger.log(`[SYNC] Full fill for order ${orderId} (slot ${matchedGridOrder.id}).`, 'info');
1175
- const spreadOrder = convertToSpreadPlaceholder(matchedGridOrder);
1176
- const fullOk = await mgr._updateOrder(spreadOrder, 'handle-fill-full', { skipAccounting: false, fee: 0 });
1167
+ const fullOk = await mgr._updateOrder(result.fullUpdate, 'handle-fill-full', { skipAccounting: false, fee: 0 });
1177
1168
  if (fullOk === false) {
1178
1169
  mgr.logger.log(`[SYNC] Failed to convert filled order ${orderId} to spread placeholder`, 'warn');
1179
1170
  }
1180
- filledOrders.push(filledOrder);
1171
+ filledOrders.push(result.filledOrder);
1181
1172
  return { filledOrders, updatedOrders, partialFill: false };
1182
1173
  }
1183
1174
  else {
1184
- mgr.logger.log(`[SYNC] Partial fill for order ${orderId} (slot ${matchedGridOrder.id}): newSize=${newSize}`, 'info');
1185
- const filledPortion = {
1186
- ...matchedGridOrder,
1187
- size: filledAmount,
1188
- isPartial: true,
1189
- blockNum: blockNum,
1190
- historyId: historyId,
1191
- isMaker: isMaker // Preserve maker/taker flag for accurate fee calculation
1192
- };
1193
- const { btsFeeState, ...matchedWithoutDeferredFee } = matchedGridOrder;
1194
- let updatedOrder = { ...matchedWithoutDeferredFee, state: ORDER_STATES.PARTIAL };
1195
- // Update cached raw order integer instead of deleting it.
1196
- // If the drift refetch above pulled a fresh chain value, use
1197
- // it as the new baseline (subtracting this fill) and stamp
1198
- // fetchedAt so the cache is consistent for the next fill.
1199
- if (updatedOrder.rawOnChain && updatedOrder.rawOnChain.for_sale !== undefined) {
1200
- const baselineForSale = (chainRefetched && Number.isFinite(effectiveRawForSale))
1201
- ? effectiveRawForSale
1202
- : toFiniteNumber(updatedOrder.rawOnChain.for_sale);
1203
- const nextForSaleInt = Math.max(0, Math.round(baselineForSale) - filledAmountInt);
1204
- updatedOrder.rawOnChain = {
1205
- ...updatedOrder.rawOnChain,
1206
- for_sale: String(nextForSaleInt),
1207
- fetchedAt: Date.now()
1208
- };
1209
- }
1210
- const nextOrder = await applyChainSizeToGridOrder(mgr, updatedOrder, newSize);
1211
- if (nextOrder) {
1212
- updatedOrder = { ...updatedOrder, ...nextOrder };
1213
- }
1214
- const partialOk = await mgr._updateOrder(updatedOrder, 'handle-fill-partial', { skipAccounting: false, fee: 0 });
1175
+ mgr.logger.log(`[SYNC] Partial fill for order ${orderId} (slot ${matchedGridOrder.id}): newSize=${result.newSize}`, 'info');
1176
+ const partialOk = await mgr._updateOrder(result.partialUpdate, 'handle-fill-partial', { skipAccounting: false, fee: 0 });
1215
1177
  if (partialOk === false) {
1216
1178
  mgr.logger.log(`[SYNC] Failed to update partially filled order ${orderId}`, 'warn');
1217
1179
  }
1218
- updatedOrders.push(updatedOrder);
1219
- filledOrders.push(filledPortion);
1180
+ updatedOrders.push(result.partialUpdate);
1181
+ filledOrders.push(result.filledOrder);
1220
1182
  return { filledOrders, updatedOrders, partialFill: true };
1221
1183
  }
1222
1184
  }
@@ -1228,6 +1190,225 @@ class SyncEngine {
1228
1190
  mgr.unlockOrders([...orderIdsToLock]);
1229
1191
  }
1230
1192
  }
1193
+ /**
1194
+ * Process multiple fill-history events in a batch, acquiring _gridLock once.
1195
+ *
1196
+ * Locking: all order IDs locked once up-front; fund recalc paused once.
1197
+ *
1198
+ * @param {Array} fills - Array of fill history event objects (same block group)
1199
+ * @param {Object} [options] - Persistence mode options
1200
+ * @returns {Promise<import('./types').BatchSyncResult>}
1201
+ */
1202
+ async syncFromFillHistoryBatch(fills, options = {}) {
1203
+ const mgr = this.manager;
1204
+ const persistenceMode = resolveProcessedFillPersistenceMode(options);
1205
+ if (!Array.isArray(fills) || fills.length === 0) {
1206
+ return { filledOrders: [], updatedOrders: [], partialFill: false, ghostOrderIds: [] };
1207
+ }
1208
+ // Phase 1: Extract & validate fill data
1209
+ const fillEntries = [];
1210
+ let anyRequiresSync = false;
1211
+ for (const fill of fills) {
1212
+ if (!fill || !fill.op || !fill.op[1])
1213
+ continue;
1214
+ const fillOp = fill.op[1];
1215
+ const blockNum = fill.block_num;
1216
+ const historyId = fill.id;
1217
+ const orderId = fillOp.order_id;
1218
+ if (fillOp.is_maker === undefined) {
1219
+ mgr.logger.log(`[SYNC] is_maker flag missing from fill data for order ${orderId}; defaulting to maker`, 'warn');
1220
+ }
1221
+ const isMaker = fillOp.is_maker !== false;
1222
+ const fillKey = buildFillKey({ orderId, blockNum, historyId });
1223
+ if (!fillKey) {
1224
+ mgr.logger.log(`[SYNC] Missing replay-safe fill key for order ${orderId} block ${blockNum}; deferring to open-orders sync`, 'warn');
1225
+ anyRequiresSync = true;
1226
+ continue;
1227
+ }
1228
+ const paysAmountRaw = toFiniteNumber(fillOp.pays?.amount);
1229
+ const paysAssetId = fillOp.pays ? fillOp.pays.asset_id : null;
1230
+ const receivesAmountRaw = toFiniteNumber(fillOp.receives?.amount);
1231
+ const receivesAssetId = fillOp.receives ? fillOp.receives.asset_id : null;
1232
+ mgr.logger.log(`[SYNC] Processing fill ${historyId} at block ${blockNum} for order ${orderId} (maker=${isMaker}). Pays=${paysAmountRaw}@${paysAssetId}, Receives=${receivesAmountRaw}@${receivesAssetId}`, 'debug');
1233
+ fillEntries.push({
1234
+ fill, fillOp, blockNum, historyId, orderId, fillKey,
1235
+ isMaker, paysAmountRaw, paysAssetId, receivesAmountRaw, receivesAssetId
1236
+ });
1237
+ }
1238
+ if (fillEntries.length === 0) {
1239
+ return { filledOrders: [], updatedOrders: [], partialFill: false, ghostOrderIds: [], requiresOpenOrdersSync: anyRequiresSync };
1240
+ }
1241
+ // Phase 2: Lock all unique order IDs once
1242
+ const allOrderIds = [...new Set(fillEntries.map(e => e.orderId))];
1243
+ mgr.lockOrders(allOrderIds);
1244
+ try {
1245
+ mgr.pauseFundRecalc();
1246
+ try {
1247
+ // Phase 3: Process accounting for each fill
1248
+ const validEntries = [];
1249
+ for (const entry of fillEntries) {
1250
+ try {
1251
+ const appliedAccounting = await mgr.accountant.processFillAccounting(entry.fillOp, entry.fillKey, { persistenceMode });
1252
+ if (!appliedAccounting) {
1253
+ mgr.logger.log(`[SYNC] Replay detected for fill ${entry.fillKey}; skipping duplicate order mutation`, 'debug');
1254
+ continue;
1255
+ }
1256
+ validEntries.push(entry);
1257
+ }
1258
+ catch (acctErr) {
1259
+ mgr.logger.log(`[SYNC] Accounting error for fill ${entry.fillKey}: ${acctErr.message}`, 'error');
1260
+ continue;
1261
+ }
1262
+ }
1263
+ if (validEntries.length === 0) {
1264
+ return { filledOrders: [], updatedOrders: [], partialFill: false, ghostOrderIds: [], requiresOpenOrdersSync: anyRequiresSync };
1265
+ }
1266
+ // Phase 4: Build per-fill context + identify drift candidates
1267
+ const entryContexts = [];
1268
+ const driftOrderIds = new Set();
1269
+ const assetAPrecision = mgr.assets?.assetA?.precision;
1270
+ const assetBPrecision = mgr.assets?.assetB?.precision;
1271
+ if (assetAPrecision === undefined || assetBPrecision === undefined) {
1272
+ mgr.logger?.log?.('Error: manager.assets precision missing in syncFromFillHistoryBatch', 'error');
1273
+ return { filledOrders: [], updatedOrders: [], partialFill: false, ghostOrderIds: [], requiresOpenOrdersSync: anyRequiresSync };
1274
+ }
1275
+ for (const entry of validEntries) {
1276
+ const { orderId, paysAmountRaw, paysAssetId } = entry;
1277
+ const matchedGridOrder = this._findMatchingGridOrder(mgr, orderId);
1278
+ if (!matchedGridOrder) {
1279
+ mgr.logger.log(`[SYNC] Fill for order ${orderId} ignored: order not found in active grid.`, 'debug');
1280
+ anyRequiresSync = true;
1281
+ continue;
1282
+ }
1283
+ const ctx = this._computeFillContext(mgr, matchedGridOrder, paysAssetId, paysAmountRaw);
1284
+ mgr.logger.log(`[SYNC] Order ${orderId} (${ctx.orderType}) currentSize=${ctx.currentSize}, filledAmount=${ctx.filledAmount}`, 'debug');
1285
+ if (ctx.driftSignal) {
1286
+ driftOrderIds.add(orderId);
1287
+ }
1288
+ entryContexts.push({
1289
+ entry, matchedGridOrder,
1290
+ orderType: ctx.orderType,
1291
+ precision: ctx.precision,
1292
+ filledAmount: ctx.filledAmount,
1293
+ filledAmountInt: floatToBlockchainInt(ctx.filledAmount, ctx.precision),
1294
+ currentSizeIntFromGrid: ctx.currentSizeIntFromGrid,
1295
+ rawForSaleInt: ctx.rawForSaleInt
1296
+ });
1297
+ }
1298
+ // Phase 5: Batch drift refetch — one get_objects call for all order IDs
1299
+ const refetchMap = new Map();
1300
+ if (driftOrderIds.size > 0) {
1301
+ try {
1302
+ const batchResults = await chainOrders.batchReadOrders([...driftOrderIds], 3000);
1303
+ for (const [orderId, freshOrder] of batchResults) {
1304
+ if (freshOrder) {
1305
+ const freshForSale = toFiniteNumber(freshOrder.for_sale, null);
1306
+ if (Number.isFinite(freshForSale)) {
1307
+ refetchMap.set(orderId, {
1308
+ chainConfirmsEmpty: Math.round(freshForSale) <= 0,
1309
+ chainRefetched: true,
1310
+ effectiveRawForSale: freshForSale
1311
+ });
1312
+ }
1313
+ else {
1314
+ refetchMap.set(orderId, {
1315
+ chainConfirmsEmpty: false,
1316
+ chainRefetched: true,
1317
+ effectiveRawForSale: null
1318
+ });
1319
+ }
1320
+ }
1321
+ else {
1322
+ refetchMap.set(orderId, {
1323
+ chainConfirmsEmpty: true,
1324
+ chainRefetched: true,
1325
+ effectiveRawForSale: null
1326
+ });
1327
+ }
1328
+ }
1329
+ for (const [orderId, { chainRefetched, chainConfirmsEmpty, effectiveRawForSale }] of refetchMap) {
1330
+ if (chainRefetched && effectiveRawForSale != null) {
1331
+ mgr.logger.log(`[SYNC] Drift detected for ${orderId}; batch-refetched for_sale=${effectiveRawForSale}`, 'warn');
1332
+ }
1333
+ else if (chainConfirmsEmpty) {
1334
+ mgr.logger.log(`[SYNC] Drift refetch for ${orderId} returned null; chain confirms empty`, 'info');
1335
+ }
1336
+ }
1337
+ }
1338
+ catch (refetchErr) {
1339
+ mgr.logger.log(`[SYNC] Batch drift refetch failed for ${driftOrderIds.size} orders; falling back to cache: ${refetchErr?.message || refetchErr}`, 'warn');
1340
+ }
1341
+ }
1342
+ // Phase 6: Compute state transitions and collect grid updates
1343
+ const gridUpdates = [];
1344
+ const filledOrders = [];
1345
+ const updatedOrders = [];
1346
+ const ghostOrderIds = [];
1347
+ let anyPartialFill = false;
1348
+ for (const ctx of entryContexts) {
1349
+ const { entry, matchedGridOrder, orderType, precision, filledAmount, filledAmountInt, currentSizeIntFromGrid, rawForSaleInt } = ctx;
1350
+ const { orderId, blockNum, historyId, isMaker } = entry;
1351
+ const refetchInfo = refetchMap.get(orderId);
1352
+ const chainRefetched = refetchInfo?.chainRefetched || false;
1353
+ const chainConfirmsEmpty = refetchInfo?.chainConfirmsEmpty || false;
1354
+ const effectiveRawForSale = refetchInfo?.effectiveRawForSale != null ? refetchInfo.effectiveRawForSale : rawForSaleInt;
1355
+ const result = await this._computeFillTransitionResult(mgr, {
1356
+ matchedGridOrder,
1357
+ orderType,
1358
+ precision,
1359
+ filledAmount,
1360
+ filledAmountInt,
1361
+ currentSizeIntFromGrid,
1362
+ rawForSaleInt,
1363
+ chainRefetched,
1364
+ chainConfirmsEmpty,
1365
+ effectiveRawForSale,
1366
+ blockNum,
1367
+ historyId,
1368
+ isMaker
1369
+ });
1370
+ if (result.isFull) {
1371
+ if (result.isGhost) {
1372
+ mgr.logger.log(`[SYNC] Ghost full fill for order ${orderId} (slot ${matchedGridOrder.id}): ` +
1373
+ `preserving orderId ${result.ghostOrderId} as PARTIAL to block duplicate CREATE.`, 'info');
1374
+ gridUpdates.push({ id: matchedGridOrder.id, ...result.ghostUpdate, context: 'handle-fill-ghost' });
1375
+ ghostOrderIds.push(result.ghostOrderId);
1376
+ }
1377
+ else {
1378
+ mgr.logger.log(`[SYNC] Full fill for order ${orderId} (slot ${matchedGridOrder.id}).`, 'info');
1379
+ gridUpdates.push({ id: matchedGridOrder.id, ...result.fullUpdate, context: 'handle-fill-full' });
1380
+ }
1381
+ filledOrders.push(result.filledOrder);
1382
+ }
1383
+ else {
1384
+ mgr.logger.log(`[SYNC] Partial fill for order ${orderId} (slot ${matchedGridOrder.id}): newSize=${result.newSize}`, 'info');
1385
+ gridUpdates.push({ id: matchedGridOrder.id, ...result.partialUpdate, context: 'handle-fill-partial' });
1386
+ updatedOrders.push(result.partialUpdate);
1387
+ filledOrders.push(result.filledOrder);
1388
+ anyPartialFill = true;
1389
+ }
1390
+ }
1391
+ // Phase 7: Single grid batch update — acquires _gridLock once
1392
+ if (gridUpdates.length > 0) {
1393
+ const updateObjects = gridUpdates.map(u => {
1394
+ const { context, ...orderData } = u;
1395
+ return orderData;
1396
+ });
1397
+ const batchOk = await mgr.applyGridUpdateBatch(updateObjects, 'handle-fill-batch', { skipAccounting: false, fee: 0 });
1398
+ if (batchOk === false) {
1399
+ mgr.logger.log('[SYNC] Batch grid update failed for some fills; next sync cycle will reconcile', 'warn');
1400
+ }
1401
+ }
1402
+ return { filledOrders, updatedOrders, partialFill: anyPartialFill, ghostOrderIds, requiresOpenOrdersSync: anyRequiresSync };
1403
+ }
1404
+ finally {
1405
+ await mgr.resumeFundRecalc();
1406
+ }
1407
+ }
1408
+ finally {
1409
+ mgr.unlockOrders(allOrderIds);
1410
+ }
1411
+ }
1231
1412
  /**
1232
1413
  * High-level dispatcher for different blockchain synchronization sources.
1233
1414
  * Routes to the appropriate sync strategy based on the data source.
@@ -1513,14 +1694,13 @@ class SyncEngine {
1513
1694
  const assetBId = mgr.assets?.assetB?.id;
1514
1695
  if (!assetAId || !assetBId)
1515
1696
  return;
1516
- const { getOnChainAssetBalances } = require('../chain_orders');
1517
1697
  const { NATIVE_CLIENT } = require('../constants');
1518
1698
  const assetList = [assetAId, assetBId];
1519
1699
  // For non-BTS pairs, also fetch core asset (BTS) balance for fee management
1520
1700
  if (mgr.config.assetA !== 'BTS' && mgr.config.assetB !== 'BTS') {
1521
1701
  assetList.push(NATIVE_CLIENT.CHAIN.CORE_ASSET_ID);
1522
1702
  }
1523
- const lookup = await getOnChainAssetBalances(accountIdOrName, assetList);
1703
+ const lookup = await chainOrders.getOnChainAssetBalances(accountIdOrName, assetList);
1524
1704
  const aInfo = lookup?.[assetAId] || lookup?.[mgr.config.assetA];
1525
1705
  const bInfo = lookup?.[assetBId] || lookup?.[mgr.config.assetB];
1526
1706
  if (aInfo && bInfo) {