dexbot 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -499,7 +499,7 @@ async function loadGrid(manager, grid, boundaryIdx = null) {
499
499
  return;
500
500
  return await manager._gridLock.acquire(async () => {
501
501
  try {
502
- await manager._initializeAssets();
502
+ await (0, system_1.withBlockchainRetry)(() => manager._initializeAssets(), 'initializeAssets', { logger: manager.logger });
503
503
  }
504
504
  catch (e) {
505
505
  manager.logger?.log?.(`Asset initialization failed during grid load: ${(0, errors_1.getErrorMessage)(e)}`, 'warn');
@@ -596,7 +596,12 @@ async function loadGrid(manager, grid, boundaryIdx = null) {
596
596
  async function initializeGrid(manager) {
597
597
  if (!manager)
598
598
  throw new Error('initializeGrid requires a manager instance');
599
- await manager._initializeAssets();
599
+ try {
600
+ await (0, system_1.withBlockchainRetry)(() => manager._initializeAssets(), 'initializeAssets', { logger: manager.logger });
601
+ }
602
+ catch (e) {
603
+ manager.logger?.log?.(`Asset initialization failed during grid init: ${(0, errors_1.getErrorMessage)(e)}`, 'warn');
604
+ }
600
605
  // FIX: Add explicit state validation to prevent cryptic errors later
601
606
  if (!manager.assets || !manager.assets.assetA || !manager.assets.assetB) {
602
607
  throw new Error('Asset initialization did not complete properly - assetA or assetB undefined');
@@ -867,49 +872,82 @@ async function recalculateGrid(manager, opts) {
867
872
  const { readOpenOrdersFn, chainOrders, account, privateKey } = opts;
868
873
  // Suppress invariant warnings during full resync
869
874
  manager.startBootstrap();
870
- try {
871
- // FIX: Use consistent optional chaining pattern for logger calls
872
- manager.logger?.log?.('Starting full resync...', 'info');
873
- await manager._initializeAssets();
874
- await manager.fetchAccountTotals();
875
- const READ_OPEN_ORDERS_TIMEOUT_MS = 30000;
876
- let _readOpenOrdersTimer;
877
- let chainOpenOrders;
875
+ // Total timeout across all steps — prevents indefinite hang even if
876
+ // an individual withBlockchainRetry step pins the event loop.
877
+ const totalTimeoutMs = constants_1.PIPELINE_TIMING.TIMEOUT_MS * 2; // 10 min
878
+ let _resyncAborted = false;
879
+ const work = (async () => {
878
880
  try {
879
- chainOpenOrders = await Promise.race([
880
- readOpenOrdersFn(),
881
- new Promise((_, reject) => {
882
- _readOpenOrdersTimer = setTimeout(() => reject(new Error('readOpenOrders timeout')), READ_OPEN_ORDERS_TIMEOUT_MS);
883
- })
884
- ]);
881
+ manager.logger?.log?.('Starting full resync...', 'info');
882
+ if (_resyncAborted)
883
+ return;
884
+ // #1: Initialize assets with timeout + retry + node failover
885
+ try {
886
+ await (0, system_1.withBlockchainRetry)(() => manager._initializeAssets(), 'initializeAssets', { logger: manager.logger });
887
+ }
888
+ catch (e) {
889
+ manager.logger?.log?.(`Asset initialization failed during resync: ${(0, errors_1.getErrorMessage)(e)}`, 'warn');
890
+ }
891
+ if (_resyncAborted)
892
+ return;
893
+ // #2: Fetch account totals with timeout + retry + node failover
894
+ await (0, system_1.withBlockchainRetry)(() => manager.fetchAccountTotals(), 'fetchAccountTotals', { logger: manager.logger });
895
+ if (_resyncAborted)
896
+ return;
897
+ // #3: Read open orders with timeout + retry + node failover
898
+ const chainOpenOrders = await (0, system_1.withBlockchainRetry)(() => readOpenOrdersFn(), 'readOpenOrders', { logger: manager.logger });
899
+ if (_resyncAborted)
900
+ return;
901
+ if (!Array.isArray(chainOpenOrders))
902
+ return;
903
+ await (0, system_1.withBlockchainRetry)(() => manager.syncFromOpenOrders(chainOpenOrders, { skipAccounting: true }), 'syncFromOpenOrders', { logger: manager.logger });
904
+ if (_resyncAborted)
905
+ return;
906
+ manager.resetFunds();
907
+ if (_resyncAborted)
908
+ return;
909
+ await manager.persistGrid();
910
+ if (_resyncAborted)
911
+ return;
912
+ await initializeGrid(manager);
913
+ if (_resyncAborted)
914
+ return;
915
+ const { reconcileGridOrders } = require('./grid_reconcile');
916
+ // #5: Reconcile grid orders with timeout + retry + node failover
917
+ try {
918
+ await (0, system_1.withBlockchainRetry)(() => reconcileGridOrders({ manager, config: manager.config, account, privateKey, chainOrders, chainOpenOrders }), 'reconcileGridOrders',
919
+ // 5 min: Phase 2 of reconcile does sequential creates (~3s each);
920
+ // the default 30s timeout would kill mid-batch and cause duplicate-
921
+ // accumulation death spirals. PIPELINE_TIMING.TIMEOUT_MS gives enough
922
+ // headroom for all pending creates+updates to finish in one shot.
923
+ { logger: manager.logger, timeoutMs: constants_1.PIPELINE_TIMING.TIMEOUT_MS });
924
+ }
925
+ catch (err) {
926
+ manager.logger?.log?.(`Error during startup order reconciliation: ${(0, errors_1.getErrorMessage)(err)}`, 'error');
927
+ throw new Error(`Grid recalculation failed during order reconciliation: ${(0, errors_1.getErrorMessage)(err)}`);
928
+ }
929
+ if (_resyncAborted)
930
+ return;
931
+ manager.logger?.log?.('Full resync complete.', 'info');
885
932
  }
886
933
  finally {
887
- clearTimeout(_readOpenOrdersTimer);
934
+ manager.finishBootstrap();
888
935
  }
889
- if (!Array.isArray(chainOpenOrders))
890
- return;
891
- // CRITICAL: Filter out PARTIAL orders before synchronizing - they're from old grid
892
- // and shouldn't be part of the fresh regenerated grid structure
893
- const activeOrders = chainOpenOrders.filter((o) => o.state !== constants_1.ORDER_STATES.PARTIAL);
894
- await manager.syncFromOpenOrders(activeOrders, { skipAccounting: true });
895
- manager.resetFunds();
896
- await manager.persistGrid();
897
- await initializeGrid(manager);
898
- const { reconcileGridOrders } = require('./grid_reconcile');
899
- // FIX: Add error context for debugging grid recalculation issues
900
- try {
901
- await reconcileGridOrders({ manager, config: manager.config, account, privateKey, chainOrders, chainOpenOrders });
902
- }
903
- catch (err) {
904
- manager.logger?.log?.(`Error during startup order reconciliation: ${(0, errors_1.getErrorMessage)(err)}`, 'error');
905
- throw new Error(`Grid recalculation failed during order reconciliation: ${(0, errors_1.getErrorMessage)(err)}`);
906
- }
907
- // FIX: Use consistent optional chaining pattern for logger calls
908
- manager.logger?.log?.('Full resync complete.', 'info');
909
- }
910
- finally {
911
- manager.finishBootstrap();
912
- }
936
+ })();
937
+ // Swallow late rejection if timeout wins the race
938
+ Promise.resolve(work).catch(() => { });
939
+ let timeoutId;
940
+ const result = await Promise.race([
941
+ work,
942
+ new Promise((_, reject) => {
943
+ timeoutId = setTimeout(() => {
944
+ _resyncAborted = true;
945
+ reject(new Error(`recalculateGrid timed out after ${totalTimeoutMs}ms`));
946
+ }, totalTimeoutMs);
947
+ })
948
+ ]);
949
+ clearTimeout(timeoutId);
950
+ return result;
913
951
  }
914
952
  /**
915
953
  * Check for grid divergence and trigger update if threshold is met.
@@ -1465,8 +1503,21 @@ async function checkSpreadCondition(manager, _BitShares, updateOrdersOnChainBatc
1465
1503
  correction = await prepareSpreadCorrectionOrders(manager, decision.side);
1466
1504
  if (!correction)
1467
1505
  return false;
1468
- const placeCount = correction.ordersToPlace?.length || 0;
1469
- const updateCount = correction.ordersToUpdate?.length || 0;
1506
+ let placeCount = correction.ordersToPlace?.length || 0;
1507
+ let updateCount = correction.ordersToUpdate?.length || 0;
1508
+ // STARVATION FALLBACK: If the selected side has no correctable slots (e.g.
1509
+ // all SPREAD slots already filled or misaligned), try the opposite side.
1510
+ if ((placeCount + updateCount) === 0) {
1511
+ const oppositeSide = decision.side === constants_1.ORDER_TYPES.BUY ? constants_1.ORDER_TYPES.SELL : constants_1.ORDER_TYPES.BUY;
1512
+ manager.logger?.log?.(`[SPREAD] Side ${decision.side} produced zero candidates; ` +
1513
+ `trying opposite side ${oppositeSide}.`, 'debug');
1514
+ const oppositeCorrection = await prepareSpreadCorrectionOrders(manager, oppositeSide);
1515
+ if (oppositeCorrection) {
1516
+ correction = oppositeCorrection;
1517
+ placeCount = correction.ordersToPlace?.length || 0;
1518
+ updateCount = correction.ordersToUpdate?.length || 0;
1519
+ }
1520
+ }
1470
1521
  // Capture fund snapshot under lock for pre-flight verification before broadcast
1471
1522
  fundSnapshot = _snapshotFundState(manager);
1472
1523
  return (placeCount + updateCount) > 0;
@@ -1492,22 +1543,32 @@ async function checkSpreadCondition(manager, _BitShares, updateOrdersOnChainBatc
1492
1543
  // Instead of silently aborting, re-plan with fresh funds so the
1493
1544
  // correction still applies on this cycle. The pre-flight check
1494
1545
  // still guards against placing orders based on stale fund snapshots.
1495
- const decision = determineOrderSideByFunds(manager, lastPrice);
1496
- if (decision.side) {
1497
- correction = await prepareSpreadCorrectionOrders(manager, decision.side);
1498
- if (correction && ((correction.ordersToPlace?.length || 0) + (correction.ordersToUpdate?.length || 0) > 0)) {
1499
- fundSnapshot = currentFunds;
1500
- manager.logger?.log?.(`[SPREAD] Fund state changed between lock release and broadcast — ` +
1501
- `re-planned with updated funds: ${correction.ordersToPlace?.length || 0} creates, ` +
1502
- `${correction.ordersToUpdate?.length || 0} updates`, 'info');
1503
- }
1504
- else {
1505
- manager.logger?.log?.(`[SPREAD] Fund state changed; re-plan produced no viable orders. Skipping cycle.`, 'warn');
1506
- return { ordersPlaced: 0, partialsMoved: 0 };
1507
- }
1546
+ // Re-acquire _gridLock for the re-plan to ensure consistent grid
1547
+ // state (the lock is re-entrant for this call chain — the outer
1548
+ // acquire's callback completed before we reach here, so there is
1549
+ // no nested lock to recurse into). If determineOrderSideByFunds
1550
+ // or prepareSpreadCorrectionOrders grow to hold the lock for
1551
+ // heavy work, hoist the result to avoid serial re-execution.
1552
+ const rePlanResult = await manager._gridLock.acquire(async () => {
1553
+ const decision = determineOrderSideByFunds(manager, lastPrice);
1554
+ if (!decision.side)
1555
+ return { side: false };
1556
+ const c = await prepareSpreadCorrectionOrders(manager, decision.side);
1557
+ return { side: true, correction: c };
1558
+ });
1559
+ if (!rePlanResult.side) {
1560
+ manager.logger?.log?.(`[SPREAD] Fund state changed; no side has sufficient funds for re-plan. Skipping cycle.`, 'warn');
1561
+ return { ordersPlaced: 0, partialsMoved: 0 };
1562
+ }
1563
+ correction = rePlanResult.correction;
1564
+ if (correction && ((correction.ordersToPlace?.length || 0) + (correction.ordersToUpdate?.length || 0) > 0)) {
1565
+ fundSnapshot = currentFunds;
1566
+ manager.logger?.log?.(`[SPREAD] Fund state changed between lock release and broadcast — ` +
1567
+ `re-planned with updated funds: ${correction.ordersToPlace?.length || 0} creates, ` +
1568
+ `${correction.ordersToUpdate?.length || 0} updates`, 'info');
1508
1569
  }
1509
1570
  else {
1510
- manager.logger?.log?.(`[SPREAD] Fund state changed; no side has sufficient funds for re-plan. Skipping cycle.`, 'warn');
1571
+ manager.logger?.log?.(`[SPREAD] Fund state changed; re-plan produced no viable orders. Skipping cycle.`, 'warn');
1511
1572
  return { ordersPlaced: 0, partialsMoved: 0 };
1512
1573
  }
1513
1574
  }
@@ -1825,16 +1886,65 @@ async function prepareSpreadCorrectionOrders(manager, preferredSide) {
1825
1886
  edgePartial = partials[0];
1826
1887
  manager.logger?.log?.(`[SPREAD-CORRECTION] Identified partial order at ${edgePartial.price} for update`, 'debug');
1827
1888
  }
1828
- // Primary candidates: SPREAD-type slots adjacent to the gap.
1889
+ // Boundary-correct type computation. Used by both candidate pools below to ensure
1890
+ // spread correction does not re-activate slots whose current boundary position
1891
+ // places them on the wrong side or in the spread zone — doing so would compound
1892
+ // inventory at prices where the bot already traded.
1893
+ //
1894
+ // The natural type of a slot is derived from its position in the price-sorted rail
1895
+ // relative to boundaryIdx + gapSlots: indices in [0, boundaryIdx] are BUY, indices
1896
+ // in [boundaryIdx + gapSlots + 1, N-1] are SELL, the middle band is SPREAD.
1897
+ const allSlotsByPrice = allOrders
1898
+ .filter((o) => o.price != null && Number.isFinite(o.price))
1899
+ .sort((a, b) => a.price - b.price);
1900
+ const slotIndexMap = new Map(allSlotsByPrice.map((o, i) => [o.id, i]));
1901
+ const gapSlots = calculateGapSlots(manager.config?.incrementPercent, manager.config?.targetSpreadPercent, manager.config?.gridLimits);
1902
+ // Sync boundary from current fund state to avoid stale boundaryIdx causing
1903
+ // getSlotCorrectType to misclassify slots (e.g. after fills shifted the
1904
+ // boundary but the COW commit hasn't updated it yet). This is safe under
1905
+ // the grid lock — no concurrent modifications can race with this read.
1906
+ const boundarySync = (0, system_1.syncBoundaryToFunds)(manager);
1907
+ if (boundarySync.changed && boundarySync.newIdx !== undefined) {
1908
+ manager.boundaryIdx = boundarySync.newIdx;
1909
+ }
1910
+ const bIdx = manager.boundaryIdx ?? 0;
1911
+ const buyEndIdx = bIdx;
1912
+ const sellStartIdx = bIdx + Number(gapSlots) + 1;
1913
+ const getSlotCorrectType = (slot) => {
1914
+ const idx = slotIndexMap.get(slot.id);
1915
+ if (idx === undefined)
1916
+ return slot.type;
1917
+ if (idx <= buyEndIdx)
1918
+ return constants_1.ORDER_TYPES.BUY;
1919
+ if (idx >= sellStartIdx)
1920
+ return constants_1.ORDER_TYPES.SELL;
1921
+ return constants_1.ORDER_TYPES.SPREAD;
1922
+ };
1923
+ // Primary candidates: SPREAD-type slots adjacent to the gap. Filter by
1924
+ // boundary-correct type so a SPREAD slot that, after a boundary shift, now sits
1925
+ // in the BUY or SELL zone is excluded — it would otherwise be placed on the
1926
+ // correction side at a price the grid already considers the opposite side.
1829
1927
  const typedSpreadCandidates = allOrders
1830
- .filter((o) => o.type === constants_1.ORDER_TYPES.SPREAD && (0, order_1.isSlotAvailable)(o))
1928
+ .filter((o) => o.type === constants_1.ORDER_TYPES.SPREAD
1929
+ && (0, order_1.isSlotAvailable)(o)
1930
+ && getSlotCorrectType(o) === railType)
1831
1931
  .sort((a, b) => railType === constants_1.ORDER_TYPES.BUY ? a.price - b.price : b.price - a.price)
1832
1932
  .slice(0, missingSlots);
1833
1933
  // Secondary candidates: orphaned virtual slots of the correct side-type that have
1834
1934
  // lost their order (e.g. stale-cleaned after a race condition during a crash).
1835
1935
  // These sit inside the active window and are invisible to the SPREAD-type filter above.
1936
+ //
1937
+ // IMPORTANT: Filter by boundary-correct type so that filled-then-virtualized slots
1938
+ // whose boundary position has moved into the spread or opposite zone are NOT
1939
+ // re-activated on the stale side — doing so would compound inventory at prices
1940
+ // where the bot already traded. The boundary-correct type is computed from the
1941
+ // current boundary index and the slot's price position in the sorted rail.
1836
1942
  const orphanedVirtualCandidates = allOrders
1837
- .filter((o) => o.type === railType && o.state === constants_1.ORDER_STATES.VIRTUAL && !o.orderId && Number(o.size || 0) === 0)
1943
+ .filter((o) => o.type === railType
1944
+ && o.state === constants_1.ORDER_STATES.VIRTUAL
1945
+ && !o.orderId
1946
+ && Number(o.size || 0) === 0
1947
+ && getSlotCorrectType(o) === railType)
1838
1948
  .sort((a, b) => railType === constants_1.ORDER_TYPES.BUY ? b.price - a.price : a.price - b.price)
1839
1949
  .slice(0, missingSlots);
1840
1950
  // Merge: prefer orphaned virtuals (they already occupy correct grid positions) then