dexbot 1.4.7 → 1.4.9

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 (130) hide show
  1. package/README.md +4 -5
  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 +3 -0
  6. package/dist/dexbot.d.ts.map +1 -1
  7. package/dist/dexbot.js +81 -28
  8. package/dist/dexbot.js.map +1 -1
  9. package/dist/modules/account_bots.d.ts.map +1 -1
  10. package/dist/modules/account_bots.js +14 -24
  11. package/dist/modules/account_bots.js.map +1 -1
  12. package/dist/modules/bitshares-native/transport.d.ts.map +1 -1
  13. package/dist/modules/bitshares-native/transport.js +21 -0
  14. package/dist/modules/bitshares-native/transport.js.map +1 -1
  15. package/dist/modules/broadcast_failure.d.ts +21 -0
  16. package/dist/modules/broadcast_failure.d.ts.map +1 -0
  17. package/dist/modules/broadcast_failure.js +95 -0
  18. package/dist/modules/broadcast_failure.js.map +1 -0
  19. package/dist/modules/chain_keys.d.ts +2 -2
  20. package/dist/modules/chain_keys.d.ts.map +1 -1
  21. package/dist/modules/chain_keys.js +30 -56
  22. package/dist/modules/chain_keys.js.map +1 -1
  23. package/dist/modules/chain_orders.d.ts +79 -1
  24. package/dist/modules/chain_orders.d.ts.map +1 -1
  25. package/dist/modules/chain_orders.js +154 -10
  26. package/dist/modules/chain_orders.js.map +1 -1
  27. package/dist/modules/constants.d.ts +1 -0
  28. package/dist/modules/constants.d.ts.map +1 -1
  29. package/dist/modules/constants.js +21 -7
  30. package/dist/modules/constants.js.map +1 -1
  31. package/dist/modules/credit_runtime.d.ts.map +1 -1
  32. package/dist/modules/credit_runtime.js +26 -0
  33. package/dist/modules/credit_runtime.js.map +1 -1
  34. package/dist/modules/daemon_node_health.d.ts +33 -0
  35. package/dist/modules/daemon_node_health.d.ts.map +1 -0
  36. package/dist/modules/daemon_node_health.js +120 -0
  37. package/dist/modules/daemon_node_health.js.map +1 -0
  38. package/dist/modules/dexbot_class.d.ts +12 -3
  39. package/dist/modules/dexbot_class.d.ts.map +1 -1
  40. package/dist/modules/dexbot_class.js +19 -5
  41. package/dist/modules/dexbot_class.js.map +1 -1
  42. package/dist/modules/dexbot_cow_runtime.d.ts +46 -1
  43. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  44. package/dist/modules/dexbot_cow_runtime.js +754 -64
  45. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  46. package/dist/modules/dexbot_credential_client.d.ts +15 -0
  47. package/dist/modules/dexbot_credential_client.d.ts.map +1 -1
  48. package/dist/modules/dexbot_credential_client.js +114 -141
  49. package/dist/modules/dexbot_credential_client.js.map +1 -1
  50. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  51. package/dist/modules/dexbot_fill_runtime.js +45 -15
  52. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  53. package/dist/modules/dexbot_maintenance_runtime.d.ts +5 -1
  54. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  55. package/dist/modules/dexbot_maintenance_runtime.js +237 -84
  56. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  57. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  58. package/dist/modules/dexbot_startup_runtime.js +95 -33
  59. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  60. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  61. package/dist/modules/dexbot_state_recovery.js +24 -2
  62. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  63. package/dist/modules/key_store.d.ts.map +1 -1
  64. package/dist/modules/key_store.js +5 -4
  65. package/dist/modules/key_store.js.map +1 -1
  66. package/dist/modules/launcher/credential_daemon.d.ts.map +1 -1
  67. package/dist/modules/launcher/credential_daemon.js +15 -3
  68. package/dist/modules/launcher/credential_daemon.js.map +1 -1
  69. package/dist/modules/node_failure_ledger.d.ts +39 -0
  70. package/dist/modules/node_failure_ledger.d.ts.map +1 -0
  71. package/dist/modules/node_failure_ledger.js +88 -0
  72. package/dist/modules/node_failure_ledger.js.map +1 -0
  73. package/dist/modules/node_health_cache.d.ts +2 -1
  74. package/dist/modules/node_health_cache.d.ts.map +1 -1
  75. package/dist/modules/node_health_cache.js +25 -0
  76. package/dist/modules/node_health_cache.js.map +1 -1
  77. package/dist/modules/node_manager.d.ts +3 -0
  78. package/dist/modules/node_manager.d.ts.map +1 -1
  79. package/dist/modules/node_manager.js +41 -10
  80. package/dist/modules/node_manager.js.map +1 -1
  81. package/dist/modules/order/accounting.d.ts +1 -1
  82. package/dist/modules/order/accounting.d.ts.map +1 -1
  83. package/dist/modules/order/accounting.js +219 -101
  84. package/dist/modules/order/accounting.js.map +1 -1
  85. package/dist/modules/order/grid.d.ts.map +1 -1
  86. package/dist/modules/order/grid.js +66 -14
  87. package/dist/modules/order/grid.js.map +1 -1
  88. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  89. package/dist/modules/order/grid_reconcile.js +130 -56
  90. package/dist/modules/order/grid_reconcile.js.map +1 -1
  91. package/dist/modules/order/grid_reconcile_internal.d.ts +2 -1
  92. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  93. package/dist/modules/order/grid_reconcile_internal.js +333 -54
  94. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  95. package/dist/modules/order/manager.d.ts +97 -4
  96. package/dist/modules/order/manager.d.ts.map +1 -1
  97. package/dist/modules/order/manager.js +377 -92
  98. package/dist/modules/order/manager.js.map +1 -1
  99. package/dist/modules/order/strategy.d.ts.map +1 -1
  100. package/dist/modules/order/strategy.js +24 -1
  101. package/dist/modules/order/strategy.js.map +1 -1
  102. package/dist/modules/order/sync_engine.d.ts +16 -3
  103. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  104. package/dist/modules/order/sync_engine.js +329 -247
  105. package/dist/modules/order/sync_engine.js.map +1 -1
  106. package/dist/modules/order/utils/math.d.ts +79 -1
  107. package/dist/modules/order/utils/math.d.ts.map +1 -1
  108. package/dist/modules/order/utils/math.js +18 -0
  109. package/dist/modules/order/utils/math.js.map +1 -1
  110. package/dist/modules/order/utils/order.d.ts +44 -1
  111. package/dist/modules/order/utils/order.d.ts.map +1 -1
  112. package/dist/modules/order/utils/order.js +91 -5
  113. package/dist/modules/order/utils/order.js.map +1 -1
  114. package/dist/modules/order/utils/system.d.ts +5 -1
  115. package/dist/modules/order/utils/system.d.ts.map +1 -1
  116. package/dist/modules/order/utils/system.js +7 -5
  117. package/dist/modules/order/utils/system.js.map +1 -1
  118. package/dist/modules/order/utils/withPoolRef.d.ts.map +1 -1
  119. package/dist/modules/order/utils/withPoolRef.js +39 -4
  120. package/dist/modules/order/utils/withPoolRef.js.map +1 -1
  121. package/dist/modules/socket_json_client.d.ts +14 -0
  122. package/dist/modules/socket_json_client.d.ts.map +1 -0
  123. package/dist/modules/socket_json_client.js +100 -0
  124. package/dist/modules/socket_json_client.js.map +1 -0
  125. package/dist/unlock.d.ts +4 -4
  126. package/dist/unlock.js +6 -6
  127. package/dist/unlock.js.map +1 -1
  128. package/package.json +2 -1
  129. package/scripts/clear-all.sh +4 -4
  130. package/scripts/clear-logs.sh +4 -4
@@ -111,9 +111,11 @@ var __importStar = (this && this.__importStar) || (function () {
111
111
  Object.defineProperty(exports, "__esModule", { value: true });
112
112
  const constants_1 = require("../constants");
113
113
  const system_1 = require("./utils/system");
114
+ const order_1 = require("./utils/order");
114
115
  const Format = __importStar(require("./format"));
115
116
  const fundRegistry = __importStar(require("../fund_registry"));
116
117
  const chainOrders = __importStar(require("../chain_orders"));
118
+ const chain_orders_1 = require("../chain_orders");
117
119
  const math_1 = require("./utils/math");
118
120
  const processed_fill_store_1 = require("./processed_fill_store");
119
121
  const errors_1 = require("../utils/errors");
@@ -340,8 +342,20 @@ class Accountant {
340
342
  const mgr = this.manager;
341
343
  if (mgr._pauseFundRecalc > 0)
342
344
  return;
343
- if (!mgr.funds)
344
- 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
+ }
345
359
  // Sync btsBalance from manager into funds for non-BTS pairs
346
360
  if (mgr.btsBalance) {
347
361
  mgr.funds.btsBalance = { ...mgr.funds.btsBalance, ...mgr.btsBalance };
@@ -365,8 +379,8 @@ class Accountant {
365
379
  // - SPREAD type: derive from price relation to startPrice (market midpoint)
366
380
  // * price < startPrice → BUY side (lower prices are bids)
367
381
  // * price >= startPrice → SELL side (higher prices are asks)
368
- const isBuy = order.type === constants_1.ORDER_TYPES.BUY || (order.type === constants_1.ORDER_TYPES.SPREAD && order.price < mgr.config.startPrice);
369
- 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);
370
384
  if (isBuy) {
371
385
  if (isActive) {
372
386
  gridBuy += size;
@@ -457,6 +471,19 @@ class Accountant {
457
471
  * @private
458
472
  */
459
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
+ }
460
487
  const buyPrecision = mgr.assets?.assetB?.precision;
461
488
  const sellPrecision = mgr.assets?.assetA?.precision;
462
489
  if (!Number.isFinite(buyPrecision) || !Number.isFinite(sellPrecision)) {
@@ -588,7 +615,25 @@ class Accountant {
588
615
  // proceeds, so the temporary invariant tolerance is no longer needed.
589
616
  mgr._orphanFillsCreditedAt = null;
590
617
  // 2. Sync from open orders
591
- 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
+ }
592
637
  // Recovery runs after fetchAccountTotals() has refreshed authoritative balances
593
638
  // from chain. During this pass we only want to reconcile grid structure/order
594
639
  // mapping against open orders; re-applying optimistic accounting deltas here
@@ -692,6 +737,19 @@ class Accountant {
692
737
  mgr.logger?.log?.(`[RECOVERY] ${violationType} - attempting state recovery (attempt ${state.attemptCount}${hasAttemptLimit ? `/${maxAttemptsRaw}` : ''})...`, 'warn');
693
738
  try {
694
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
+ }
695
753
  if (validation.isValid) {
696
754
  mgr.logger?.log?.('[RECOVERY] State recovery succeeded', 'info');
697
755
  state.structuralResyncRequested = false;
@@ -941,92 +999,142 @@ class Accountant {
941
999
  const mgr = this.manager;
942
1000
  if (!oldOrder || !newOrder)
943
1001
  return;
944
- // Ensure a mutable copy: _resolveBtsFeeLifecycle mutates btsFeeState on
945
- // newOrder (line 230), but the caller may pass a frozen master-grid order.
946
- if (Object.isFrozen(newOrder)) {
947
- newOrder = { ...newOrder };
948
- }
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;
949
1009
  if (!skipAssetAccounting) {
950
1010
  const oldIsActive = (oldOrder.state === constants_1.ORDER_STATES.ACTIVE || oldOrder.state === constants_1.ORDER_STATES.PARTIAL);
951
1011
  const newIsActive = (newOrder.state === constants_1.ORDER_STATES.ACTIVE || newOrder.state === constants_1.ORDER_STATES.PARTIAL);
952
- const oldSize = toFiniteNumber(oldOrder.size);
953
- const newSize = toFiniteNumber(newOrder.size);
954
- // 1. Handle Capital Commitment (Moves between FREE and LOCKED)
955
- // For COMMITMENT: Use GRID state (isActive), not blockchain ID
956
- const oldGridCommitted = oldIsActive ? oldSize : 0;
957
- const newGridCommitted = newIsActive ? newSize : 0;
958
- const commitmentDelta = newGridCommitted - oldGridCommitted;
959
- const newSideType = this._resolveOrderSide(newOrder, oldOrder);
960
- const oldSideType = this._resolveOrderSide(oldOrder, newOrder);
961
- const sideForPrecision = newSideType || oldSideType;
962
- if (mgr.logger && mgr.logger.level === 'debug') {
963
- mgr.logger.log(`[ACCOUNTING] updateOptimisticFreeBalance: id=${newOrder.id}, type=${newOrder.type}, ` +
964
- `state=${oldOrder.state}->${newOrder.state}, ` +
965
- `size=${Format.formatSizeByOrderType(oldSize, sideForPrecision ?? '', mgr.assets)}->${Format.formatSizeByOrderType(newSize, sideForPrecision ?? '', mgr.assets)}, ` +
966
- `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
+ }
967
1022
  }
968
- if (commitmentDelta > 0) {
969
- // Lock capital: move from Free to Committed
970
- const commitmentSide = newSideType || newOrder.type;
971
- const result = await this.tryDeductFromChainFree(commitmentSide, commitmentDelta, `${context}`);
972
- if (!result.ok) {
973
- const failure = {
974
- code: result.reason === 'stale' ? 'ACCOUNTING_STALE_ACCOUNT_TOTALS' : 'ACCOUNTING_COMMITMENT_FAILED',
975
- side: commitmentSide,
976
- amount: commitmentDelta,
977
- context,
978
- reason: result.reason,
979
- at: Date.now()
980
- };
981
- mgr._lastAccountingFailure = failure;
982
- if (result.reason === 'stale') {
983
- mgr.logger?.log?.(`[ACCOUNTING] Stale accountTotals: skipping optimistic lock of ${Format.formatAmount8(commitmentDelta)} for ${commitmentSide} during ${context}. Recovery scheduled.`, 'warn');
984
- // Schedule recovery but DON'T throw — staleness is transient.
985
- // The batch already confirmed on-chain; aborting the commit
986
- // would orphan a successful broadcast from the grid state.
987
- }
988
- else {
989
- mgr.logger?.log?.(`[ACCOUNTING] CRITICAL: Failed to lock ${Format.formatAmount8(commitmentDelta)} for ${commitmentSide} during ${context}. Scheduling recovery.`, 'error');
990
- if (mgr._throwOnIllegalState) {
991
- const err = new Error(`CRITICAL ACCOUNTING STATE: failed to lock ${Format.formatAmount8(commitmentDelta)} ${commitmentSide} during ${context}`);
992
- err.code = 'ACCOUNTING_COMMITMENT_FAILED';
993
- 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');
994
1070
  }
995
1071
  }
996
- // Promote recovery from fire-and-forget to a stored promise.
997
- // Subsequent callers can await the in-flight recovery before
998
- // attempting new deductions, reducing redundant recovery cycles.
999
- if (!mgr._pendingRecovery) {
1000
- mgr._pendingRecovery = this._attemptFundRecovery(mgr, 'Optimistic commitment deduction failure')
1001
- .catch((err) => {
1002
- mgr.logger?.log?.(`[RECOVERY] Immediate recovery scheduling failed: ${(0, errors_1.getErrorMessage)(err)}`, 'error');
1003
- mgr._recoveryState = { ...mgr._recoveryState, lastFailureAt: Date.now() };
1004
- })
1005
- .finally(() => {
1006
- mgr._pendingRecovery = null;
1007
- });
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');
1008
1078
  }
1009
- else {
1010
- 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
+ }
1011
1119
  }
1012
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
+ }
1013
1126
  }
1014
- else if (commitmentDelta < 0) {
1015
- // Release capital: move from Committed back to Free
1016
- const releaseSide = oldSideType || oldOrder.type;
1017
- await this.addToChainFree(releaseSide, Math.abs(commitmentDelta), `${context}`);
1018
- }
1019
- }
1020
- // 2. Handle BTS blockchain fee lifecycle.
1021
- // BitShares stores create/update fees as order.deferred_fee and later
1022
- // refunds or charges that deferred fee on fill, update, or cancel.
1023
- const btsOrderType = this._getBtsOrderType();
1024
- if (btsOrderType) {
1025
- const { balanceDelta } = this._resolveBtsFeeLifecycle(oldOrder, newOrder, context, fee);
1026
- if (Math.abs(balanceDelta) > 0) {
1027
- await 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
+ }
1028
1136
  }
1029
- }
1137
+ });
1030
1138
  }
1031
1139
  /**
1032
1140
  * Deduct BTS fees using adjustTotalBalance.
@@ -1040,10 +1148,9 @@ class Accountant {
1040
1148
  */
1041
1149
  async deductBtsFees(requestedSide = null) {
1042
1150
  const mgr = this.manager;
1043
- // Early returns for no work needed
1044
- if (!mgr.funds || !mgr.funds.btsFeesOwed || mgr.funds.btsFeesOwed <= 0)
1045
- return;
1046
- 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)
1047
1154
  return;
1048
1155
  const btsSide = (0, math_1.getBtsSide)(mgr.config?.assetA, mgr.config?.assetB);
1049
1156
  const normalizedRequestedSide = (requestedSide === 'buy' || requestedSide === 'sell') ? requestedSide : null;
@@ -1054,26 +1161,37 @@ class Accountant {
1054
1161
  }
1055
1162
  if (!side)
1056
1163
  return;
1057
- const fees = mgr.funds.btsFeesOwed;
1058
- const orderType = (side === 'buy') ? constants_1.ORDER_TYPES.BUY : constants_1.ORDER_TYPES.SELL;
1059
- const freeKey = (side === 'buy') ? 'buyFree' : 'sellFree';
1060
- const chainFree = mgr.accountTotals[freeKey] || 0;
1061
- // SUFFICIENCY CHECK: Defer if insufficient funds
1062
- 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;
1063
1185
  if (mgr.logger && mgr.logger.level === 'debug') {
1064
- 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');
1065
1187
  }
1066
- return;
1067
- }
1068
- // FULL DEDUCTION from chainFree
1069
- await this.adjustTotalBalance(orderType, -fees, 'bts-fee-settlement');
1070
- if (mgr.logger && mgr.logger.level === 'debug') {
1071
- 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();
1072
1194
  }
1073
- // Reset fees after successful settlement
1074
- mgr.funds.btsFeesOwed = 0;
1075
- // Recalculate funds to update all tracking metrics
1076
- await mgr.recalculateFunds();
1077
1195
  }
1078
1196
  /**
1079
1197
  * Validate that a proposed Target Grid fits within the account's total available funds.