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
@@ -7,7 +7,7 @@
7
7
  * Exports a single Accountant class that manages all fund accounting operations.
8
8
  *
9
9
  * ===============================================================================
10
- * TABLE OF CONTENTS - Accountant Class (18 methods)
10
+ * TABLE OF CONTENTS - Accountant Class (19 methods)
11
11
  * ===============================================================================
12
12
  *
13
13
  * CORE INITIALIZATION & RECALCULATION (2 methods)
@@ -19,7 +19,7 @@
19
19
  * Called after any state change. Aggregates committed/available funds and triggers allocation.
20
20
  *
21
21
  * VERIFICATION & RECOVERY (3 methods - async, internal)
22
- * 4. _verifyFundInvariants(mgr, chainFreeBuy, chainFreeSell, chainBuy, chainSell) - Verify fund tracking invariants
22
+ * 4. _verifyFundInvariants(mgr, chainFreeBuy, chainFreeSell, chainBuy, chainSell, actualBuy, actualSell) - Verify fund tracking invariants
23
23
  * 5. _performStateRecovery(mgr) - Centralized state recovery (fetch + sync + validate) (async, internal)
24
24
  * 6. _attemptFundRecovery(mgr, violationType) - Attempt immediate recovery from invariant violations (async, internal)
25
25
  *
@@ -27,25 +27,26 @@
27
27
  * 7. tryDeductFromChainFree(orderType, size, operation) - Atomically deduct from FREE portion
28
28
  * 8. addToChainFree(orderType, size, operation) - Add amount back to optimistic chainFree balance
29
29
  *
30
- * BALANCE ADJUSTMENTS (4 methods)
31
- * 9. adjustTotalBalance(orderType, delta, operation) - Adjust total and free balances
32
- * 10. _normalizeSideHint(sideHint) - Normalize side hint to standard key (internal)
33
- * 11. _resolveOrderSide(order, fallbackOrder, explicitSideHint) - Resolve order side (internal)
34
- * 12. updateOptimisticFreeBalance(oldOrder, newOrder, context, fee, skipAssetAccounting) - Update optimistic balance during transitions
30
+ * BALANCE ADJUSTMENTS (5 methods)
31
+ * 9. adjustTotalBalance(orderType, delta, operation) - Acquires _fundLock, adjusts total and free balances
32
+ * 10. _adjustTotalBalanceLocked(orderType, delta, operation) - PRIVATE: body of adjustTotalBalance, caller must hold _fundLock
33
+ * 11. _normalizeSideHint(sideHint) - Normalize side hint to standard key (internal)
34
+ * 12. _resolveOrderSide(order, fallbackOrder, explicitSideHint) - Resolve order side (internal)
35
+ * 13. updateOptimisticFreeBalance(oldOrder, newOrder, context, fee, skipAssetAccounting) - Update optimistic balance during transitions
35
36
  *
36
37
  * FEE MANAGEMENT (2 methods)
37
- * 13. deductBtsFees(requestedSide) - Deduct BTS fees using adjustTotalBalance with deferral strategy (async)
38
- * 14. _deductFeesFromProceeds(assetSymbol, rawAmount, isMaker) - Deduct fees from fill proceeds (internal)
38
+ * 14. deductBtsFees(requestedSide) - Deduct BTS fees using adjustTotalBalance with deferral strategy (async)
39
+ * 15. _deductFeesFromProceeds(assetSymbol, rawAmount, isMaker) - Deduct fees from fill proceeds (internal)
39
40
  *
40
41
  * FILL BALANCE TRACKING (1 method)
41
- * 15. recordFillBalances(paysAsset, paysAmount, receivesAsset, receivesAmount, context) - Record fill proceeds (async)
42
+ * 16. recordFillBalances(paysAsset, paysAmount, receivesAsset, receivesAmount, context) - Record fill proceeds (async)
42
43
  *
43
44
  * FILL PROCESSING (1 method)
44
- * 16. processFillAccounting(fillOp, fillKey, persistenceMode) - Process fund impact of order fill (atomically updates accountTotals)
45
+ * 17. processFillAccounting(fillOp, fillKey, persistenceMode) - Process fund impact of order fill (atomically updates accountTotals)
45
46
  *
46
47
  * RECOVERY & VALIDATION (2 methods)
47
- * 17. resetRecoveryState() - Reset recovery backoff and state
48
- * 18. validateTargetGrid(targetGrid, mgr) - Validate target grid fund requirements
48
+ * 18. resetRecoveryState() - Reset recovery backoff and state
49
+ * 19. validateTargetGrid(targetGrid, mgr) - Validate target grid fund requirements
49
50
  *
50
51
  * ===============================================================================
51
52
  * FUND STRUCTURE (managed by Accountant)
@@ -110,9 +111,11 @@ var __importStar = (this && this.__importStar) || (function () {
110
111
  Object.defineProperty(exports, "__esModule", { value: true });
111
112
  const constants_1 = require("../constants");
112
113
  const system_1 = require("./utils/system");
114
+ const order_1 = require("./utils/order");
113
115
  const Format = __importStar(require("./format"));
114
116
  const fundRegistry = __importStar(require("../fund_registry"));
115
117
  const chainOrders = __importStar(require("../chain_orders"));
118
+ const chain_orders_1 = require("../chain_orders");
116
119
  const math_1 = require("./utils/math");
117
120
  const processed_fill_store_1 = require("./processed_fill_store");
118
121
  const errors_1 = require("../utils/errors");
@@ -160,9 +163,9 @@ class Accountant {
160
163
  * @param {Array<{orderType: string, delta: number, operation: string}>} balanceAdjustments
161
164
  * @returns {void}
162
165
  */
163
- _applyBalanceAdjustments(balanceAdjustments) {
166
+ async _applyBalanceAdjustments(balanceAdjustments) {
164
167
  for (const adjustment of balanceAdjustments) {
165
- this.adjustTotalBalance(adjustment.orderType, adjustment.delta, adjustment.operation);
168
+ await this.adjustTotalBalance(adjustment.orderType, adjustment.delta, adjustment.operation);
166
169
  }
167
170
  }
168
171
  _getBtsOrderType() {
@@ -339,8 +342,20 @@ class Accountant {
339
342
  const mgr = this.manager;
340
343
  if (mgr._pauseFundRecalc > 0)
341
344
  return;
342
- if (!mgr.funds)
343
- this.resetFunds();
345
+ if (!mgr.funds) {
346
+ // Lazy funds init mutates mgr.funds wholesale, so run it under
347
+ // _fundLock when available (reentrant when the caller already
348
+ // holds it, e.g. manager.recalculateFunds). Guards the direct
349
+ // accountant.recalculateFunds() path against unlocked mutation.
350
+ if (mgr._fundLock) {
351
+ await mgr._fundLock.acquire(async () => {
352
+ this.resetFunds();
353
+ });
354
+ }
355
+ else {
356
+ this.resetFunds();
357
+ }
358
+ }
344
359
  // Sync btsBalance from manager into funds for non-BTS pairs
345
360
  if (mgr.btsBalance) {
346
361
  mgr.funds.btsBalance = { ...mgr.funds.btsBalance, ...mgr.btsBalance };
@@ -364,8 +379,8 @@ class Accountant {
364
379
  // - SPREAD type: derive from price relation to startPrice (market midpoint)
365
380
  // * price < startPrice → BUY side (lower prices are bids)
366
381
  // * price >= startPrice → SELL side (higher prices are asks)
367
- const isBuy = order.type === constants_1.ORDER_TYPES.BUY || (order.type === constants_1.ORDER_TYPES.SPREAD && order.price < mgr.config.startPrice);
368
- const isSell = order.type === constants_1.ORDER_TYPES.SELL || (order.type === constants_1.ORDER_TYPES.SPREAD && order.price >= mgr.config.startPrice);
382
+ const isBuy = order.type === constants_1.ORDER_TYPES.BUY || (order.type === constants_1.ORDER_TYPES.SPREAD && (0, order_1.resolveSpreadOrderSide)(order.price, mgr.config.startPrice) === constants_1.ORDER_TYPES.BUY);
383
+ const isSell = order.type === constants_1.ORDER_TYPES.SELL || (order.type === constants_1.ORDER_TYPES.SPREAD && (0, order_1.resolveSpreadOrderSide)(order.price, mgr.config.startPrice) === constants_1.ORDER_TYPES.SELL);
369
384
  if (isBuy) {
370
385
  if (isActive) {
371
386
  gridBuy += size;
@@ -413,10 +428,10 @@ class Accountant {
413
428
  }
414
429
  }
415
430
  if (mgr._pauseFundRecalc === 0 && !mgr.isBootstrapping() && !mgr.isBroadcastingActive()) {
416
- const snapshot = { chainFreeBuy, chainFreeSell, chainBuy, chainSell };
431
+ const snapshot = { chainFreeBuy, chainFreeSell, chainBuy, chainSell, actualBuy: mgr.accountTotals?.buy, actualSell: mgr.accountTotals?.sell };
417
432
  const runVerification = (nextSnapshot) => {
418
433
  this._isVerifyingInvariants = true;
419
- this._verifyFundInvariants(mgr, nextSnapshot.chainFreeBuy, nextSnapshot.chainFreeSell, nextSnapshot.chainBuy, nextSnapshot.chainSell)
434
+ this._verifyFundInvariants(mgr, nextSnapshot.chainFreeBuy, nextSnapshot.chainFreeSell, nextSnapshot.chainBuy, nextSnapshot.chainSell, nextSnapshot.actualBuy, nextSnapshot.actualSell)
420
435
  .catch((err) => {
421
436
  mgr.logger?.log?.(`[RECOVERY] Verification error: ${(0, errors_1.getErrorMessage)(err)}`, 'error');
422
437
  })
@@ -455,7 +470,20 @@ class Accountant {
455
470
  * @returns {void}
456
471
  * @private
457
472
  */
458
- async _verifyFundInvariants(mgr, chainFreeBuy, chainFreeSell, chainBuy, chainSell) {
473
+ async _verifyFundInvariants(mgr, chainFreeBuy, chainFreeSell, chainBuy, chainSell, actualBuy, actualSell) {
474
+ // Half-baked guard: while a fill batch is mid-accounting, the balance
475
+ // snapshot may already reflect the just-filled orders on-chain while
476
+ // the grid still counts them as committed (or the reverse during
477
+ // optimistic fill adjustments). Running Total = Free + Committed in
478
+ // that window produces a spurious CRITICAL equal to the batch size.
479
+ // The check is deferred until the batch settles (grid mutation +
480
+ // authoritative re-anchor) and is re-run by the resume recalculation.
481
+ if ((mgr._fillBatchInFlight ?? 0) > 0) {
482
+ if (mgr.logger?.level === 'debug') {
483
+ mgr.logger.log('[INVARIANT] Fill batch in flight; deferring fund-invariant check until batch settles.', 'debug');
484
+ }
485
+ return;
486
+ }
459
487
  const buyPrecision = mgr.assets?.assetB?.precision;
460
488
  const sellPrecision = mgr.assets?.assetA?.precision;
461
489
  if (!Number.isFinite(buyPrecision) || !Number.isFinite(sellPrecision)) {
@@ -484,7 +512,6 @@ class Accountant {
484
512
  // actualBuy = mgr.accountTotals.buy (what blockchain says)
485
513
  // If |actualBuy - expectedBuy| > tolerance → fund tracking corruption detected
486
514
  const expectedBuy = chainFreeBuy + chainBuy;
487
- const actualBuy = mgr.accountTotals?.buy;
488
515
  const diffBuy = Math.abs((actualBuy ?? expectedBuy) - expectedBuy);
489
516
  const allowedBuyTolerance = Math.max(precisionSlackBuy, (actualBuy || expectedBuy) * effectivePercentTolerance);
490
517
  if (actualBuy !== null && actualBuy !== undefined && diffBuy > allowedBuyTolerance) {
@@ -495,7 +522,6 @@ class Accountant {
495
522
  this._logThrottled('fund-invariant-buy', `CRITICAL: Fund invariant violation (BUY): blockchainTotal (${Format.formatAmountByPrecision(actualBuy, buyPrecision)}) != trackedTotal (${Format.formatAmountByPrecision(expectedBuy, buyPrecision)}) (diff: ${Format.formatAmountByPrecision(diffBuy, buyPrecision)}, allowed: ${Format.formatAmountByPrecision(allowedBuyTolerance, buyPrecision)}${orphanFillsAt != null ? ', orphan-fill buffer active' : ''})`, orphanFillsAt != null ? 'warn' : 'error');
496
523
  }
497
524
  const expectedSell = chainFreeSell + chainSell;
498
- const actualSell = mgr.accountTotals?.sell;
499
525
  const diffSell = Math.abs((actualSell ?? expectedSell) - expectedSell);
500
526
  const allowedSellTolerance = Math.max(precisionSlackSell, (actualSell || expectedSell) * effectivePercentTolerance);
501
527
  if (actualSell !== null && actualSell !== undefined && diffSell > allowedSellTolerance) {
@@ -589,7 +615,25 @@ class Accountant {
589
615
  // proceeds, so the temporary invariant tolerance is no longer needed.
590
616
  mgr._orphanFillsCreditedAt = null;
591
617
  // 2. Sync from open orders
592
- const openOrders = await chainOrders.readOpenOrders(accountRef);
618
+ // An empty/truncated read is ambiguous — the account is either genuinely
619
+ // empty or the node is lagging/capped (fresh orders omitted). Running
620
+ // syncFromOpenOrders on it would let pass-1 phantom cleanup virtualize
621
+ // ACTIVE/PARTIAL slots that are live on chain, after which the next
622
+ // cycle re-creates them as duplicates. Skip the sync and defer to the
623
+ // next reconcile cycle, mirroring the other recovery-read guards.
624
+ const openOrders = await (0, chain_orders_1.readOpenOrdersGuarded)(chainOrders, accountRef, {
625
+ log: (message, level) => mgr.logger?.log?.(message, level),
626
+ label: 'RECOVERY',
627
+ detail: 'during state recovery',
628
+ deferEmpty: true,
629
+ });
630
+ if (openOrders === null) {
631
+ return {
632
+ isValid: false,
633
+ deferred: true,
634
+ reason: 'Recovery sync skipped: ambiguous chain read (truncated or empty) - node may be lagging; deferring to next reconcile cycle',
635
+ };
636
+ }
593
637
  // Recovery runs after fetchAccountTotals() has refreshed authoritative balances
594
638
  // from chain. During this pass we only want to reconcile grid structure/order
595
639
  // mapping against open orders; re-applying optimistic accounting deltas here
@@ -693,6 +737,19 @@ class Accountant {
693
737
  mgr.logger?.log?.(`[RECOVERY] ${violationType} - attempting state recovery (attempt ${state.attemptCount}${hasAttemptLimit ? `/${maxAttemptsRaw}` : ''})...`, 'warn');
694
738
  try {
695
739
  const validation = await this._performStateRecovery(mgr);
740
+ if (validation?.deferred) {
741
+ // Ambiguous (empty/truncated) chain read: the recovery was
742
+ // DEFERRED, not failed. Roll back the attempt increment so an
743
+ // ambiguous snapshot can never burn the attempt budget toward
744
+ // MAX_RECOVERY_ATTEMPTS (which blocks all CREATEs until the
745
+ // next fill/sync cycle), and skip the structural resync — a
746
+ // full grid reset driven by an ambiguous read would
747
+ // virtualize live slots via pass-1 phantom cleanup.
748
+ state.attemptCount = Math.max(0, state.attemptCount - 1);
749
+ state.lastFailureAt = 0;
750
+ mgr.logger?.log?.(`[RECOVERY] State recovery deferred (${validation.reason}); attempt not counted`, 'warn');
751
+ return false;
752
+ }
696
753
  if (validation.isValid) {
697
754
  mgr.logger?.log?.('[RECOVERY] State recovery succeeded', 'info');
698
755
  state.structuralResyncRequested = false;
@@ -840,23 +897,22 @@ class Accountant {
840
897
  // Determine orientation
841
898
  if (paysAsset === assetA) {
842
899
  // Bot paid assetA (Selling assetA, buying assetB)
843
- this.adjustTotalBalance(constants_1.ORDER_TYPES.SELL, -paysAmount, `${context}-pays`);
844
- this.adjustTotalBalance(constants_1.ORDER_TYPES.BUY, receivesAmount, `${context}-receives`);
900
+ this._adjustTotalBalanceLocked(constants_1.ORDER_TYPES.SELL, -paysAmount, `${context}-pays`);
901
+ this._adjustTotalBalanceLocked(constants_1.ORDER_TYPES.BUY, receivesAmount, `${context}-receives`);
845
902
  }
846
903
  else {
847
904
  // Bot paid assetB (Buying assetA, selling assetB)
848
- this.adjustTotalBalance(constants_1.ORDER_TYPES.BUY, -paysAmount, `${context}-pays`);
849
- this.adjustTotalBalance(constants_1.ORDER_TYPES.SELL, receivesAmount, `${context}-receives`);
905
+ this._adjustTotalBalanceLocked(constants_1.ORDER_TYPES.BUY, -paysAmount, `${context}-pays`);
906
+ this._adjustTotalBalanceLocked(constants_1.ORDER_TYPES.SELL, receivesAmount, `${context}-receives`);
850
907
  }
851
908
  });
852
909
  }
853
910
  /**
854
- * Adjust both total and free balances (for fills, fees, deposits).
855
- * @param {string} orderType - ORDER_TYPES.BUY or ORDER_TYPES.SELL
856
- * @param {number} delta - Amount to adjust
857
- * @param {string} operation - Context for logging
911
+ * PRIVATE: Must be called while holding _fundLock.
912
+ * Body of adjustTotalBalance; takes the lock externally for clean
913
+ * nesting in callers that already hold the lock (e.g. recordFillBalances).
858
914
  */
859
- adjustTotalBalance(orderType, delta, operation) {
915
+ _adjustTotalBalanceLocked(orderType, delta, operation) {
860
916
  const mgr = this.manager;
861
917
  const isBuy = (orderType === constants_1.ORDER_TYPES.BUY);
862
918
  const freeKey = isBuy ? 'buyFree' : 'sellFree';
@@ -876,6 +932,16 @@ class Accountant {
876
932
  mgr.logger.log(`[ACCOUNTING] ${totalKey} ${delta >= 0 ? '+' : ''}${Format.formatAmount8(delta)} (${operation}) -> Total: ${Format.formatAmount8(mgr.accountTotals[totalKey])}, Free: ${Format.formatAmount8(mgr.accountTotals[freeKey])}`, 'debug');
877
933
  }
878
934
  }
935
+ /**
936
+ * PUBLIC API: Acquires _fundLock.
937
+ * Adjust both total and free balances (for fills, fees, deposits).
938
+ * @returns {Promise<void>}
939
+ */
940
+ async adjustTotalBalance(orderType, delta, operation) {
941
+ await this.manager._fundLock.acquire(async () => {
942
+ this._adjustTotalBalanceLocked(orderType, delta, operation);
943
+ });
944
+ }
879
945
  /**
880
946
  * Normalize side hint to standard ORDER_TYPES constant.
881
947
  * @param {string|null} sideHint - Side hint ('buy', 'sell', ORDER_TYPES.BUY, ORDER_TYPES.SELL, or null)
@@ -933,92 +999,142 @@ class Accountant {
933
999
  const mgr = this.manager;
934
1000
  if (!oldOrder || !newOrder)
935
1001
  return;
936
- // Ensure a mutable copy: _resolveBtsFeeLifecycle mutates btsFeeState on
937
- // newOrder (line 230), but the caller may pass a frozen master-grid order.
938
- if (Object.isFrozen(newOrder)) {
939
- newOrder = { ...newOrder };
940
- }
1002
+ // If this transition will LOCK capital, ensure a fresh accountTotals snapshot
1003
+ // BEFORE acquiring _fundLock. The refresh is a live chain RPC (30s timeout,
1004
+ // 3 retries, then node failover) — running it while holding _fundLock would
1005
+ // stall every other fund-critical waiter (deductBtsFees, setAccountTotals,
1006
+ // persistGrid) and, past the 30s acquire timeout, force them to throw instead
1007
+ // of deferring. Only the plain mutation runs under the lock.
1008
+ let preLockRefreshFailed = false;
941
1009
  if (!skipAssetAccounting) {
942
1010
  const oldIsActive = (oldOrder.state === constants_1.ORDER_STATES.ACTIVE || oldOrder.state === constants_1.ORDER_STATES.PARTIAL);
943
1011
  const newIsActive = (newOrder.state === constants_1.ORDER_STATES.ACTIVE || newOrder.state === constants_1.ORDER_STATES.PARTIAL);
944
- const oldSize = toFiniteNumber(oldOrder.size);
945
- const newSize = toFiniteNumber(newOrder.size);
946
- // 1. Handle Capital Commitment (Moves between FREE and LOCKED)
947
- // For COMMITMENT: Use GRID state (isActive), not blockchain ID
948
- const oldGridCommitted = oldIsActive ? oldSize : 0;
949
- const newGridCommitted = newIsActive ? newSize : 0;
950
- const commitmentDelta = newGridCommitted - oldGridCommitted;
951
- const newSideType = this._resolveOrderSide(newOrder, oldOrder);
952
- const oldSideType = this._resolveOrderSide(oldOrder, newOrder);
953
- const sideForPrecision = newSideType || oldSideType;
954
- if (mgr.logger && mgr.logger.level === 'debug') {
955
- mgr.logger.log(`[ACCOUNTING] updateOptimisticFreeBalance: id=${newOrder.id}, type=${newOrder.type}, ` +
956
- `state=${oldOrder.state}->${newOrder.state}, ` +
957
- `size=${Format.formatSizeByOrderType(oldSize, sideForPrecision ?? '', mgr.assets)}->${Format.formatSizeByOrderType(newSize, sideForPrecision ?? '', mgr.assets)}, ` +
958
- `delta=${Format.formatSizeByOrderType(commitmentDelta, sideForPrecision ?? '', mgr.assets)}, context=${context}`, 'debug');
1012
+ const willLock = (newIsActive ? toFiniteNumber(newOrder.size) : 0) - (oldIsActive ? toFiniteNumber(oldOrder.size) : 0) > 0;
1013
+ if (willLock) {
1014
+ try {
1015
+ const pre = await mgr.refreshAccountTotalsIfStale();
1016
+ preLockRefreshFailed = !pre.ok;
1017
+ }
1018
+ catch (err) {
1019
+ preLockRefreshFailed = true;
1020
+ mgr.logger?.log?.(`[ACCOUNTING] pre-lock accountTotals refresh error: ${(0, errors_1.getErrorMessage)(err)}`, 'warn');
1021
+ }
959
1022
  }
960
- if (commitmentDelta > 0) {
961
- // Lock capital: move from Free to Committed
962
- const commitmentSide = newSideType || newOrder.type;
963
- const result = await this.tryDeductFromChainFree(commitmentSide, commitmentDelta, `${context}`);
964
- if (!result.ok) {
965
- const failure = {
966
- code: result.reason === 'stale' ? 'ACCOUNTING_STALE_ACCOUNT_TOTALS' : 'ACCOUNTING_COMMITMENT_FAILED',
967
- side: commitmentSide,
968
- amount: commitmentDelta,
969
- context,
970
- reason: result.reason,
971
- at: Date.now()
972
- };
973
- mgr._lastAccountingFailure = failure;
974
- if (result.reason === 'stale') {
975
- mgr.logger?.log?.(`[ACCOUNTING] Stale accountTotals: skipping optimistic lock of ${Format.formatAmount8(commitmentDelta)} for ${commitmentSide} during ${context}. Recovery scheduled.`, 'warn');
976
- // Schedule recovery but DON'T throw — staleness is transient.
977
- // The batch already confirmed on-chain; aborting the commit
978
- // would orphan a successful broadcast from the grid state.
979
- }
980
- else {
981
- mgr.logger?.log?.(`[ACCOUNTING] CRITICAL: Failed to lock ${Format.formatAmount8(commitmentDelta)} for ${commitmentSide} during ${context}. Scheduling recovery.`, 'error');
982
- if (mgr._throwOnIllegalState) {
983
- const err = new Error(`CRITICAL ACCOUNTING STATE: failed to lock ${Format.formatAmount8(commitmentDelta)} ${commitmentSide} during ${context}`);
984
- err.code = 'ACCOUNTING_COMMITMENT_FAILED';
985
- throw err;
1023
+ }
1024
+ return await mgr._fundLock.acquire(async () => {
1025
+ // Ensure a copy: _resolveBtsFeeLifecycle mutates btsFeeState on
1026
+ // newOrder (line 230), but the caller may pass a frozen master-grid order.
1027
+ if (Object.isFrozen(newOrder)) {
1028
+ newOrder = { ...newOrder };
1029
+ }
1030
+ if (!skipAssetAccounting) {
1031
+ const oldIsActive = (oldOrder.state === constants_1.ORDER_STATES.ACTIVE || oldOrder.state === constants_1.ORDER_STATES.PARTIAL);
1032
+ const newIsActive = (newOrder.state === constants_1.ORDER_STATES.ACTIVE || newOrder.state === constants_1.ORDER_STATES.PARTIAL);
1033
+ const oldSize = toFiniteNumber(oldOrder.size);
1034
+ const newSize = toFiniteNumber(newOrder.size);
1035
+ // 1. Handle Capital Commitment (Moves between FREE and LOCKED)
1036
+ // For COMMITMENT: Use GRID state (isActive), not blockchain ID
1037
+ const oldGridCommitted = oldIsActive ? oldSize : 0;
1038
+ const newGridCommitted = newIsActive ? newSize : 0;
1039
+ const commitmentDelta = newGridCommitted - oldGridCommitted;
1040
+ const newSideType = this._resolveOrderSide(newOrder, oldOrder);
1041
+ const oldSideType = this._resolveOrderSide(oldOrder, newOrder);
1042
+ const sideForPrecision = newSideType || oldSideType;
1043
+ if (mgr.logger && mgr.logger.level === 'debug') {
1044
+ mgr.logger.log(`[ACCOUNTING] updateOptimisticFreeBalance: id=${newOrder.id}, type=${newOrder.type}, ` +
1045
+ `state=${oldOrder.state}->${newOrder.state}, ` +
1046
+ `size=${Format.formatSizeByOrderType(oldSize, sideForPrecision ?? '', mgr.assets)}->${Format.formatSizeByOrderType(newSize, sideForPrecision ?? '', mgr.assets)}, ` +
1047
+ `delta=${Format.formatSizeByOrderType(commitmentDelta, sideForPrecision ?? '', mgr.assets)}, context=${context}`, 'debug');
1048
+ }
1049
+ if (commitmentDelta > 0) {
1050
+ // Lock capital: move from Free to Committed
1051
+ const commitmentSide = newSideType || newOrder.type;
1052
+ let result = await this.tryDeductFromChainFree(commitmentSide, commitmentDelta, `${context}`);
1053
+ // Fix staleness in the first place: a stale snapshot refused
1054
+ // the lock. When the pre-lock refresh was skipped (not needed)
1055
+ // or is in flight and this deduction still races a fresh window,
1056
+ // refresh accountTotals from chain and retry once. But if the
1057
+ // pre-lock refresh already FAILED, the chain is unhealthy — do
1058
+ // NOT re-run the 30s/3-retry/node-failover RPC while holding
1059
+ // _fundLock (that would stall every other fund-critical waiter
1060
+ // exactly like the regression this rework eliminated). Fall
1061
+ // through to the failure/recovery path below instead.
1062
+ if (!result.ok && result.reason === 'stale' && !preLockRefreshFailed) {
1063
+ mgr.logger?.log?.(`[ACCOUNTING] Stale accountTotals during ${context}; refreshing from chain before retrying optimistic lock.`, 'warn');
1064
+ const refresh = await mgr.refreshAccountTotalsIfStale();
1065
+ if (refresh.ok) {
1066
+ result = await this.tryDeductFromChainFree(commitmentSide, commitmentDelta, `${context} (post-refresh retry)`);
1067
+ }
1068
+ else {
1069
+ mgr.logger?.log?.(`[ACCOUNTING] accountTotals refresh failed (${refresh.reason}); cannot retry optimistic lock of ${Format.formatAmount8(commitmentDelta)} for ${commitmentSide}.`, 'warn');
986
1070
  }
987
1071
  }
988
- // Promote recovery from fire-and-forget to a stored promise.
989
- // Subsequent callers can await the in-flight recovery before
990
- // attempting new deductions, reducing redundant recovery cycles.
991
- if (!mgr._pendingRecovery) {
992
- mgr._pendingRecovery = this._attemptFundRecovery(mgr, 'Optimistic commitment deduction failure')
993
- .catch((err) => {
994
- mgr.logger?.log?.(`[RECOVERY] Immediate recovery scheduling failed: ${(0, errors_1.getErrorMessage)(err)}`, 'error');
995
- mgr._recoveryState = { ...mgr._recoveryState, lastFailureAt: Date.now() };
996
- })
997
- .finally(() => {
998
- mgr._pendingRecovery = null;
999
- });
1072
+ else if (!result.ok && result.reason === 'stale' && preLockRefreshFailed) {
1073
+ // The pre-lock refresh already failed (chain unhealthy) so we
1074
+ // refuse to re-run the blocking RPC under _fundLock. Fall
1075
+ // through to the stale failure/recovery path below; the batch
1076
+ // stays accounted for and recovery is scheduled.
1077
+ mgr.logger?.log?.(`[ACCOUNTING] Stale accountTotals during ${context}; pre-lock refresh failed, skipping in-lock refresh to avoid holding _fundLock across a blocking chain RPC.`, 'warn');
1000
1078
  }
1001
- else {
1002
- mgr.logger?.log?.(`[ACCOUNTING] Recovery already in-flight; waiting for existing recovery to complete before retry deduction of ${Format.formatAmount8(commitmentDelta)} for ${commitmentSide}`, 'warn');
1079
+ if (!result.ok) {
1080
+ const failure = {
1081
+ code: result.reason === 'stale' ? 'ACCOUNTING_STALE_ACCOUNT_TOTALS' : 'ACCOUNTING_COMMITMENT_FAILED',
1082
+ side: commitmentSide,
1083
+ amount: commitmentDelta,
1084
+ context,
1085
+ reason: result.reason,
1086
+ at: Date.now()
1087
+ };
1088
+ mgr._lastAccountingFailure = failure;
1089
+ if (result.reason === 'stale') {
1090
+ mgr.logger?.log?.(`[ACCOUNTING] Stale accountTotals: skipping optimistic lock of ${Format.formatAmount8(commitmentDelta)} for ${commitmentSide} during ${context}. Recovery scheduled.`, 'warn');
1091
+ // Schedule recovery but DON'T throw — staleness is transient.
1092
+ // The batch already confirmed on-chain; aborting the commit
1093
+ // would orphan a successful broadcast from the grid state.
1094
+ }
1095
+ else {
1096
+ mgr.logger?.log?.(`[ACCOUNTING] CRITICAL: Failed to lock ${Format.formatAmount8(commitmentDelta)} for ${commitmentSide} during ${context}. Scheduling recovery.`, 'error');
1097
+ if (mgr._throwOnIllegalState) {
1098
+ const err = new Error(`CRITICAL ACCOUNTING STATE: failed to lock ${Format.formatAmount8(commitmentDelta)} ${commitmentSide} during ${context}`);
1099
+ err.code = 'ACCOUNTING_COMMITMENT_FAILED';
1100
+ throw err;
1101
+ }
1102
+ }
1103
+ // Promote recovery from fire-and-forget to a stored promise.
1104
+ // Subsequent callers can await the in-flight recovery before
1105
+ // attempting new deductions, reducing redundant recovery cycles.
1106
+ if (!mgr._pendingRecovery) {
1107
+ mgr._pendingRecovery = this._attemptFundRecovery(mgr, 'Optimistic commitment deduction failure')
1108
+ .catch((err) => {
1109
+ mgr.logger?.log?.(`[RECOVERY] Immediate recovery scheduling failed: ${(0, errors_1.getErrorMessage)(err)}`, 'error');
1110
+ mgr._recoveryState = { ...mgr._recoveryState, lastFailureAt: Date.now() };
1111
+ })
1112
+ .finally(() => {
1113
+ mgr._pendingRecovery = null;
1114
+ });
1115
+ }
1116
+ else {
1117
+ mgr.logger?.log?.(`[ACCOUNTING] Recovery already in-flight; waiting for existing recovery to complete before retry deduction of ${Format.formatAmount8(commitmentDelta)} for ${commitmentSide}`, 'warn');
1118
+ }
1003
1119
  }
1004
1120
  }
1121
+ else if (commitmentDelta < 0) {
1122
+ // Release capital: move from Committed back to Free
1123
+ const releaseSide = oldSideType || oldOrder.type;
1124
+ await this.addToChainFree(releaseSide, Math.abs(commitmentDelta), `${context}`);
1125
+ }
1005
1126
  }
1006
- else if (commitmentDelta < 0) {
1007
- // Release capital: move from Committed back to Free
1008
- const releaseSide = oldSideType || oldOrder.type;
1009
- await this.addToChainFree(releaseSide, Math.abs(commitmentDelta), `${context}`);
1010
- }
1011
- }
1012
- // 2. Handle BTS blockchain fee lifecycle.
1013
- // BitShares stores create/update fees as order.deferred_fee and later
1014
- // refunds or charges that deferred fee on fill, update, or cancel.
1015
- const btsOrderType = this._getBtsOrderType();
1016
- if (btsOrderType) {
1017
- const { balanceDelta } = this._resolveBtsFeeLifecycle(oldOrder, newOrder, context, fee);
1018
- if (Math.abs(balanceDelta) > 0) {
1019
- this.adjustTotalBalance(btsOrderType, balanceDelta, `${context}-fee`);
1127
+ // 2. Handle BTS blockchain fee lifecycle.
1128
+ // BitShares stores create/update fees as order.deferred_fee and later
1129
+ // refunds or charges that deferred fee on fill, update, or cancel.
1130
+ const btsOrderType = this._getBtsOrderType();
1131
+ if (btsOrderType) {
1132
+ const { balanceDelta } = this._resolveBtsFeeLifecycle(oldOrder, newOrder, context, fee);
1133
+ if (Math.abs(balanceDelta) > 0) {
1134
+ await this.adjustTotalBalance(btsOrderType, balanceDelta, `${context}-fee`);
1135
+ }
1020
1136
  }
1021
- }
1137
+ });
1022
1138
  }
1023
1139
  /**
1024
1140
  * Deduct BTS fees using adjustTotalBalance.
@@ -1032,10 +1148,9 @@ class Accountant {
1032
1148
  */
1033
1149
  async deductBtsFees(requestedSide = null) {
1034
1150
  const mgr = this.manager;
1035
- // Early returns for no work needed
1036
- if (!mgr.funds || !mgr.funds.btsFeesOwed || mgr.funds.btsFeesOwed <= 0)
1037
- return;
1038
- if (!mgr.accountTotals)
1151
+ // Early returns for no work needed (existence checks only — value reads
1152
+ // happen inside _fundLock below so overlapping calls cannot double-deduct)
1153
+ if (!mgr.funds || !mgr.accountTotals)
1039
1154
  return;
1040
1155
  const btsSide = (0, math_1.getBtsSide)(mgr.config?.assetA, mgr.config?.assetB);
1041
1156
  const normalizedRequestedSide = (requestedSide === 'buy' || requestedSide === 'sell') ? requestedSide : null;
@@ -1046,26 +1161,37 @@ class Accountant {
1046
1161
  }
1047
1162
  if (!side)
1048
1163
  return;
1049
- const fees = mgr.funds.btsFeesOwed;
1050
- const orderType = (side === 'buy') ? constants_1.ORDER_TYPES.BUY : constants_1.ORDER_TYPES.SELL;
1051
- const freeKey = (side === 'buy') ? 'buyFree' : 'sellFree';
1052
- const chainFree = mgr.accountTotals[freeKey] || 0;
1053
- // SUFFICIENCY CHECK: Defer if insufficient funds
1054
- if (chainFree < fees) {
1164
+ let settled = false;
1165
+ // ATOMIC: read btsFeesOwed + sufficiency check + deduction + reset in one
1166
+ // lock. Two overlapping calls then serialize: the second sees fees=0 and
1167
+ // no-ops instead of double-deducting a stale amount.
1168
+ await mgr._fundLock.acquire(async () => {
1169
+ const fees = mgr.funds.btsFeesOwed;
1170
+ if (!fees || fees <= 0)
1171
+ return;
1172
+ const orderType = (side === 'buy') ? constants_1.ORDER_TYPES.BUY : constants_1.ORDER_TYPES.SELL;
1173
+ const freeKey = (side === 'buy') ? 'buyFree' : 'sellFree';
1174
+ const chainFree = mgr.accountTotals[freeKey] || 0;
1175
+ // SUFFICIENCY CHECK: Defer if insufficient funds
1176
+ if (chainFree < fees) {
1177
+ if (mgr.logger && mgr.logger.level === 'debug') {
1178
+ mgr.logger.log(`[BTS-FEE] Deferring settlement: need ${Format.formatAmount8(fees)}, have ${Format.formatAmount8(chainFree)}`, 'debug');
1179
+ }
1180
+ return;
1181
+ }
1182
+ this._adjustTotalBalanceLocked(orderType, -fees, 'bts-fee-settlement');
1183
+ mgr.funds.btsFeesOwed = 0;
1184
+ settled = true;
1055
1185
  if (mgr.logger && mgr.logger.level === 'debug') {
1056
- mgr.logger.log(`[BTS-FEE] Deferring settlement: need ${Format.formatAmount8(fees)}, have ${Format.formatAmount8(chainFree)}`, 'debug');
1186
+ mgr.logger.log(`[BTS-FEE] Settled: ${Format.formatAmount8(fees)} BTS`, 'debug');
1057
1187
  }
1058
- return;
1059
- }
1060
- // FULL DEDUCTION from chainFree
1061
- this.adjustTotalBalance(orderType, -fees, 'bts-fee-settlement');
1062
- if (mgr.logger && mgr.logger.level === 'debug') {
1063
- mgr.logger.log(`[BTS-FEE] Settled: ${Format.formatAmount8(fees)} BTS`, 'debug');
1188
+ });
1189
+ // Recalculate funds only when something was actually settled — the
1190
+ // no-op calls (no fees owed, or deferred for insufficient funds) skip
1191
+ // the full grid iteration.
1192
+ if (settled) {
1193
+ await mgr.recalculateFunds();
1064
1194
  }
1065
- // Reset fees after successful settlement
1066
- mgr.funds.btsFeesOwed = 0;
1067
- // Recalculate funds to update all tracking metrics
1068
- await mgr.recalculateFunds();
1069
1195
  }
1070
1196
  /**
1071
1197
  * Validate that a proposed Target Grid fits within the account's total available funds.
@@ -1233,23 +1359,39 @@ class Accountant {
1233
1359
  }
1234
1360
  }
1235
1361
  const processedFillStore = this.manager.processedFillStore;
1236
- // Record/queue the dedup key before applying balance adjustments. In
1237
- // immediate mode this is durable before funds move; batched/manual modes
1238
- // still preserve the existing deferred flush behavior for batch commit.
1362
+ // Queue the dedup key before applying balance adjustments. This ensures
1363
+ // persist failures are caught before any balance mutation. The queue entry
1364
+ // is flushed only after balance adjustments succeed below.
1239
1365
  if (fillKey && processedFillStore) {
1240
1366
  try {
1241
- await processedFillStore.persist(fillKey, processedAt || Date.now(), { mode: persistenceMode });
1367
+ // Queue without flush — flush follows after balance adjustments succeed.
1368
+ await processedFillStore.persist(fillKey, processedAt || Date.now(), { mode: processed_fill_store_1.PROCESSED_FILL_PERSISTENCE_MODES.MANUAL });
1242
1369
  }
1243
1370
  catch (err) {
1371
+ mgr.logger?.log?.(`[FILL-DEDUP] Failed to queue fill ${fillKey}: ${(0, errors_1.getErrorMessage)(err)}`, 'warn');
1372
+ return false;
1373
+ }
1374
+ }
1375
+ await this._applyBalanceAdjustments(balanceAdjustments);
1376
+ // Flush the queued dedup key now that balance adjustments succeeded.
1377
+ // For IMMEDIATE mode, this persists durably. For BATCHED mode, the
1378
+ // key remains queued for the caller's batch flush.
1379
+ if (fillKey && processedFillStore) {
1380
+ try {
1244
1381
  if (persistenceMode === processed_fill_store_1.PROCESSED_FILL_PERSISTENCE_MODES.IMMEDIATE) {
1245
- // Clean up any pending write that the flush error handler re-queued.
1246
- processedFillStore.discard(fillKey, processedAt || Date.now());
1247
- throw err;
1382
+ await processedFillStore.flush('fill-persist', { throwOnError: true });
1383
+ }
1384
+ }
1385
+ catch (err) {
1386
+ // Roll back balance adjustments since the dedup key was not persisted.
1387
+ // This keeps the invariant: dedup key persisted ⇔ balance adjustments applied.
1388
+ for (const adj of balanceAdjustments) {
1389
+ await this.adjustTotalBalance(adj.orderType, -adj.delta, 'rollback-' + adj.operation);
1248
1390
  }
1249
- mgr.logger?.log?.(`[FILL-DEDUP] Failed to persist fill ${fillKey}: ${(0, errors_1.getErrorMessage)(err)}`, 'warn');
1391
+ processedFillStore.discard(fillKey, processedAt || Date.now());
1392
+ throw err;
1250
1393
  }
1251
1394
  }
1252
- this._applyBalanceAdjustments(balanceAdjustments);
1253
1395
  if (fillKey) {
1254
1396
  tracker.set(fillKey, processedAt || Date.now());
1255
1397
  // Prune entries beyond the retention horizon to prevent unbounded growth.