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
@@ -104,13 +104,15 @@ const { assertPrivatePathSecurity, ensureCredentialRuntimeDirSync, getCredential
104
104
  const { buildSessionAccountCache, loadDaemonPrivateKey, } = require('./modules/credential_session_cache');
105
105
  const { fetchBootstrapPassword } = require('./modules/launcher/credential_bootstrap');
106
106
  const { normalizeBootstrapCredential } = require('./modules/launcher/credential_secret');
107
- const Logger = require('./modules/logger');
107
+ const Logger = require('./modules/logger').default;
108
108
  const { ensureDir, safeUnlink } = require('./modules/utils/fs_utils');
109
109
  const { Config } = require('./modules/config');
110
110
  const daemonLogger = new Logger('credential-daemon');
111
111
  // Resolve project root — handles running from dist/ (compiled) vs source
112
112
  const { PATHS } = require('./modules/paths');
113
113
  const { getErrorMessage } = require('./modules/utils/errors');
114
+ const { sleep } = require('./modules/order/utils/system');
115
+ const { classifyBroadcastFailure } = require('./modules/broadcast_failure');
114
116
  // Unix sockets are required; only Unix-like systems are supported
115
117
  const RUNTIME_DIR = getCredentialRuntimeDir({ root: PATHS.PROJECT_ROOT });
116
118
  const SOCKET_PATH = getCredentialSocketPath({ root: PATHS.PROJECT_ROOT, runtimeDir: RUNTIME_DIR });
@@ -136,7 +138,7 @@ let auditPruneIntervalTimer = null;
136
138
  // Signing client cache: key = `${accountName}:${keyFingerprint(wif)}`, value = full signing client + createdAt.
137
139
  // Key rotation: loadDaemonPrivateKey re-reads from vault on every call. If the WIF changes the
138
140
  // fingerprint changes → cache miss → new signing client created with the current key. No staleness.
139
- // Cleared on transport reconnect (see broadcastWithRetry). TTL-pruned (30 min) in pruneStaleSigningClients.
141
+ // Cleared on transport reconnect (see broadcastWithDeadline). TTL-pruned (30 min) in pruneStaleSigningClients.
140
142
  const signingClientCache = new Map();
141
143
  function debugLog(message, err = null) {
142
144
  const suffix = err && getErrorMessage(err) ? `: ${getErrorMessage(err)}` : '';
@@ -396,107 +398,366 @@ async function executeOperationsWithClient(client, operations) {
396
398
  operation_results: operationResults,
397
399
  };
398
400
  }
399
- async function broadcastWithRetry(accountName, privateKey, broadcastFn, nodeUrl = null) {
400
- // The inner deadline caps the TOTAL time spent across BOTH retry attempts
401
- // so we always reply to the bot well before its outer socket timer
401
+ /**
402
+ * Build a typed BROADCAST_DEADLINE error describing an uncertain broadcast.
403
+ * The bot maps this code to BroadcastUncertainError and runs verify-before-
404
+ * retry (chain read + adoption) instead of re-broadcasting.
405
+ */
406
+ function buildUncertainError(accountName, startedAt, detail) {
407
+ const err = new Error(`${DAEMON_CODES.BROADCAST_DEADLINE}:${detail}`);
408
+ err.code = DAEMON_CODES.BROADCAST_DEADLINE;
409
+ err.uncertain = true;
410
+ err.accountName = accountName;
411
+ err.startedAt = startedAt;
412
+ err.ageMs = Date.now() - startedAt;
413
+ return err;
414
+ }
415
+ /**
416
+ * Serialize daemon broadcasts: the daemon shares ONE _nativeChainClient and
417
+ * signing-client cache across all socket clients, so concurrent
418
+ * broadcastWithDeadline calls would stomp each other's node pinning, connect
419
+ * state, and cache clearing (a pre-existing hazard, widened by per-node
420
+ * pinning). Broadcasts are queued as a promise chain; non-broadcast requests
421
+ * are unaffected.
422
+ *
423
+ * The broadcast deadline guard (createBroadcastGuard) is created by the
424
+ * request handler BEFORE the queue wait, so the deadline timer covers queue
425
+ * wait + broadcast work together: total daemon wall time per request is
426
+ * capped at CREDENTIAL_DAEMON_INNER_DEADLINE_MS, well inside the bot's
427
+ * CREDENTIAL_BROADCAST_TIMEOUT_MS outer socket window. A broadcast that is
428
+ * still queued when the deadline fires aborts via guard.isFired() before it
429
+ * starts, and a client socket that dies (bot outer timeout destroys its end,
430
+ * crash, restart) fires the guard too — so a broadcast can never land on
431
+ * chain AFTER the bot already verified chain absence and re-broadcast the
432
+ * same operation.
433
+ */
434
+ let broadcastChain = Promise.resolve();
435
+ function serializeBroadcast(fn, getWork) {
436
+ const run = broadcastChain.then(fn, fn);
437
+ // The chain must wait for the WORK to settle, not the deadline-raced
438
+ // result: a deadline-aborted broadcast keeps running in the background
439
+ // (the guard only aborts it at its next checkpoint), and starting the
440
+ // next queued broadcast while that zombie still touches the shared
441
+ // _nativeChainClient would stomp its node pinning / transport state.
442
+ // getWork() is consulted AFTER run settles, when the zombie (if any) is
443
+ // known; the transport disconnect + guard checkpoints terminate it fast,
444
+ // so the queue wait stays well inside the next request's inner deadline.
445
+ broadcastChain = Promise.resolve(getWork ? run.then(() => getWork(), () => getWork()) : run)
446
+ .then(() => undefined, () => undefined);
447
+ return run;
448
+ }
449
+ /**
450
+ * Broadcast deadline guard shared between the queue wait and the broadcast
451
+ * work. The timer starts at request receipt — before the serializeBroadcast
452
+ * queue wait — so queued broadcasts cannot outlive the bot's outer socket
453
+ * window. Firing the guard (deadline exceeded, or the requesting socket
454
+ * died) aborts the queued/in-flight broadcast: the work checks isFired()
455
+ * before every attempt, so a late broadcast can never land after the bot
456
+ * already verified chain absence and re-broadcast the operation.
457
+ */
458
+ function createBroadcastGuard(accountName, startedAt, deadlineMs) {
459
+ let fired = false;
460
+ let timer = null;
461
+ let rejectGuard = null;
462
+ const promise = new Promise((_, reject) => {
463
+ rejectGuard = reject;
464
+ timer = setTimeout(() => {
465
+ fired = true;
466
+ reject(buildUncertainError(accountName, startedAt, `inner broadcast deadline ${deadlineMs}ms exceeded`));
467
+ }, deadlineMs);
468
+ });
469
+ // The guard promise is raced only once the serialized broadcast starts;
470
+ // while the request sits in the queue a deadline rejection would
471
+ // otherwise surface as an unhandled rejection.
472
+ promise.catch(() => { });
473
+ return {
474
+ isFired: () => fired,
475
+ promise,
476
+ // The candidate in play when the guard fires, set by the broadcast
477
+ // work loop (null until a candidate is attempted, or for a queued
478
+ // deadline that never started). The request handler echoes it in the
479
+ // BROADCAST_DEADLINE reply so the bot blames the node that actually
480
+ // caused the outcome — never its original preference blindly.
481
+ currentNode: null,
482
+ fire: (reason) => {
483
+ if (fired)
484
+ return;
485
+ fired = true;
486
+ if (timer) {
487
+ clearTimeout(timer);
488
+ timer = null;
489
+ }
490
+ rejectGuard(buildUncertainError(accountName, startedAt, reason));
491
+ },
492
+ clearTimer: () => {
493
+ if (timer) {
494
+ clearTimeout(timer);
495
+ timer = null;
496
+ }
497
+ }
498
+ };
499
+ }
500
+ function resolveInnerDeadlineMs() {
501
+ return Number.isFinite(Number(TIMING?.CREDENTIAL_DAEMON_INNER_DEADLINE_MS))
502
+ ? Number(TIMING.CREDENTIAL_DAEMON_INNER_DEADLINE_MS)
503
+ : 20000;
504
+ }
505
+ async function broadcastWithDeadline(accountName, privateKey, broadcastFn, nodeUrl = null, opts = {}) {
506
+ // Deadline-capped broadcast: each node gets CREDENTIAL_DAEMON_BROADCAST_RETRIES
507
+ // attempts pinned to it (the transport sweeps ONLY the pinned node), and
508
+ // only when they ALL fail with failures that provably never reached the
509
+ // chain (pre-transmit: connection setup, WebSocket not open, frame send
510
+ // errors) does the daemon report the node failure to the node health
511
+ // ledger and rotate to the next best node from the health cache. An
512
+ // uncertain failure — RPC timeout, or the connection dropped while a
513
+ // response was pending — may have landed, and re-signing it would
514
+ // duplicate the transaction on chain (each re-signing produces a new
515
+ // transaction ID), so it is NEVER retried on any node: it is reported as
516
+ // BROADCAST_DEADLINE so the bot layer verifies chain inclusion before any
517
+ // re-broadcast (executeWithRetryOnUncertain, startup adoption). The inner
518
+ // deadline caps the TOTAL wall time across all attempts — pre-transmit
519
+ // failures are fast (no RPC wait), so retries never hang the bot — and
520
+ // guarantees we
521
+ // reply well before the bot's outer socket timer
402
522
  // (CREDENTIAL_BROADCAST_TIMEOUT_MS) fires. If we don't reply in time, the
403
523
  // bot raises BroadcastUncertainError and enters the recovery path.
404
524
  // See: modules/dexbot_credential_client.ts BroadcastUncertainError.
405
- const innerDeadlineMs = Number.isFinite(Number(TIMING?.CREDENTIAL_DAEMON_INNER_DEADLINE_MS))
406
- ? Number(TIMING.CREDENTIAL_DAEMON_INNER_DEADLINE_MS)
407
- : 20000;
408
- const startedAt = Date.now();
409
- let deadlineTimer = null;
410
- const deadlinePromise = new Promise((_, reject) => {
411
- deadlineTimer = setTimeout(() => {
412
- const err = new Error(`${DAEMON_CODES.BROADCAST_DEADLINE}:inner broadcast deadline ${innerDeadlineMs}ms exceeded`);
413
- err.code = DAEMON_CODES.BROADCAST_DEADLINE;
414
- err.uncertain = true;
415
- err.accountName = accountName;
416
- err.startedAt = startedAt;
417
- err.ageMs = Date.now() - startedAt;
418
- reject(err);
419
- }, innerDeadlineMs);
420
- });
421
- const maxRetries = TIMING?.CREDENTIAL_DAEMON_BROADCAST_RETRIES ?? 2;
525
+ const innerDeadlineMs = resolveInnerDeadlineMs();
526
+ const maxRetries = Number.isFinite(Number(TIMING?.CREDENTIAL_DAEMON_BROADCAST_RETRIES))
527
+ ? Number(TIMING.CREDENTIAL_DAEMON_BROADCAST_RETRIES)
528
+ : 3;
529
+ const retryBackoffMs = Number.isFinite(Number(TIMING?.CREDENTIAL_DAEMON_BROADCAST_BACKOFF_MS))
530
+ ? Number(TIMING.CREDENTIAL_DAEMON_BROADCAST_BACKOFF_MS)
531
+ : 1000;
532
+ const startedAt = opts.startedAt ?? Date.now();
533
+ // The guard covers the queue wait too when the request handler creates
534
+ // it before serializeBroadcast (deadline timer started at request
535
+ // receipt); without one it starts here and covers only the broadcast
536
+ // work. isFired() lets the retry loop abort before any late background
537
+ // broadcast, and the finally block resets the transport even while the
538
+ // broadcast is still in flight.
539
+ const guard = opts.guard ?? createBroadcastGuard(accountName, startedAt, innerDeadlineMs);
540
+ const deadlinePromise = guard.promise;
422
541
  const work = (async () => {
423
- // If a specific node URL is requested, override the global node list
424
- // for this broadcast so the retry uses a different backend.
425
- const effectiveNodeList = nodeUrl
426
- ? [nodeUrl]
427
- : (_nativeNodeList.length > 0 ? _nativeNodeList : NODE_MANAGEMENT.DEFAULT_NODES);
428
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
429
- try {
430
- if (_nativeChainClient.getStatus() !== 'connected') {
431
- _nativeChainClient.setNodes(effectiveNodeList);
432
- await _nativeChainClient.connect();
433
- // Transport reconnected — dispose all stale signing clients (heap-dump safety)
434
- // then clear the cache so new clients use the fresh transport.
435
- for (const [, entry] of signingClientCache) {
436
- if (typeof entry.signingClient.dispose === 'function') {
437
- try {
438
- entry.signingClient.dispose();
439
- }
440
- catch (_) { }
441
- }
542
+ // Pin ALL attempts to ONE node at a time: up to `attemptsPerNode`
543
+ // attempts on the best candidate, and only when those all fail with
544
+ // provably-untransmitted errors do we report the node failure and
545
+ // rotate to the next best node.
546
+ const attemptsPerNode = Math.max(1, maxRetries);
547
+ let lastErr = null;
548
+ const exhaustedNodes = [];
549
+ // Build the ordered candidate list: the bot-supplied nodeUrl is the
550
+ // PREFERRED node (attempted first), followed by the healthy node set
551
+ // ordered best-first by the shared health cache. Re-reads the cache
552
+ // so bot-side blacklists are reflected immediately; daemon-side
553
+ // blacklists (daemonNodeHealth) are excluded until their cooldown
554
+ // expires.
555
+ const buildCandidates = () => {
556
+ refreshNodeList();
557
+ const baseList = _nativeNodeList.length > 0 ? _nativeNodeList : NODE_MANAGEMENT.DEFAULT_NODES;
558
+ const list = nodeUrl ? [nodeUrl, ...baseList.filter((n) => n !== nodeUrl)] : [...baseList];
559
+ return list.filter((n) => !exhaustedNodes.includes(n) && !daemonNodeHealth.isBlacklisted(n));
560
+ };
561
+ // Candidate pool: healthy nodes best-first. When every available node
562
+ // is daemon-blacklisted (or the health cache is empty/absent), fall
563
+ // back to the configured defaults as a LAST RESORT — rebuilt the same
564
+ // way on every rotation, so each rotation still gets its next
565
+ // best-effort node: failing the broadcast outright would stall the
566
+ // order until the bot's next cycle, and a stale blacklist (node
567
+ // recovered inside its cooldown window) would otherwise go unused.
568
+ // Ordering: never-failed/recovered nodes first, then blacklisted ones
569
+ // cooldown-soonest-first.
570
+ let lastResortWarned = false;
571
+ const buildCandidatePool = () => {
572
+ const pool = buildCandidates();
573
+ if (pool.length > 0)
574
+ return pool;
575
+ const state = daemonNodeHealth.getState();
576
+ const fallback = NODE_MANAGEMENT.DEFAULT_NODES
577
+ .filter((n) => !exhaustedNodes.includes(n))
578
+ .sort((a, b) => (state.get(a)?.blacklistedUntil ?? -Infinity) - (state.get(b)?.blacklistedUntil ?? -Infinity));
579
+ if (fallback.length > 0 && !lastResortWarned) {
580
+ lastResortWarned = true;
581
+ daemonLogger.warn?.(`[credential-daemon] All healthy candidates blacklisted; falling back to last-resort node(s) (never-failed first, then cooldown-soonest)`);
582
+ }
583
+ return fallback;
584
+ };
585
+ // candidates[0] is always the next fresh node: every rotation pushes
586
+ // the exhausted node onto the ledger and rebuilds the pool, so the
587
+ // pool shrinks until it is empty.
588
+ let candidates = buildCandidatePool();
589
+ // The candidate in play when the deadline fires, exposed to the
590
+ // request handler (via the guard) so the BROADCAST_DEADLINE reply can
591
+ // name the node that actually caused it — never the bot's original
592
+ // preference, which may be innocent after daemon-side rotation.
593
+ if (opts.guard)
594
+ opts.guard.currentNode = null;
595
+ while (candidates.length > 0) {
596
+ const candidate = candidates[0];
597
+ if (opts.guard)
598
+ opts.guard.currentNode = candidate;
599
+ for (let attempt = 1; attempt <= attemptsPerNode; attempt++) {
600
+ // Abort as soon as the guard fired (inner deadline won the
601
+ // race, or the requesting socket died): the bot has been told
602
+ // the outcome is uncertain and may verify + re-create — a
603
+ // late background broadcast would duplicate the order.
604
+ if (guard.isFired()) {
605
+ throw buildUncertainError(accountName, startedAt, 'inner broadcast deadline exceeded');
606
+ }
607
+ if (attempt > 1) {
608
+ await sleep(retryBackoffMs);
609
+ if (guard.isFired()) {
610
+ throw buildUncertainError(accountName, startedAt, 'inner broadcast deadline exceeded');
442
611
  }
443
- signingClientCache.clear();
444
612
  }
445
- const fp = keyFingerprint(String(privateKey));
446
- const cacheKey = accountName + ':' + fp;
447
- let cached = signingClientCache.get(cacheKey);
448
- if (!cached) {
449
- // Proactively evict stale entries for the same account (different fingerprint)
450
- // so key rotation never leaves dead entries in the map.
451
- const stalePrefix = accountName + ':';
452
- for (const [k, v] of signingClientCache) {
453
- if (k !== cacheKey && k.startsWith(stalePrefix)) {
454
- if (typeof v.signingClient.dispose === 'function') {
613
+ let phase = 'connect';
614
+ try {
615
+ const currentNode = _nativeChainClient.transport?.getNodeUrl?.();
616
+ if (_nativeChainClient.getStatus() !== 'connected' || currentNode !== candidate) {
617
+ phase = 'connect';
618
+ // Pin the attempt to the current candidate: the transport
619
+ // sweeps ONLY the pinned node, so all attempts genuinely
620
+ // use the SAME node before any rotation.
621
+ _nativeChainClient.setNodes([candidate]);
622
+ await _nativeChainClient.connect();
623
+ // The guard may have fired while the connect handshake was
624
+ // in flight (slow node + inner deadline). Abort here —
625
+ // before any signing-client work — so the sign+broadcast
626
+ // never happens after the bot was told BROADCAST_DEADLINE:
627
+ // a late background broadcast would duplicate the order.
628
+ if (guard.isFired()) {
629
+ throw buildUncertainError(accountName, startedAt, 'inner broadcast deadline exceeded during connect');
630
+ }
631
+ // Transport reconnected — dispose all stale signing clients (heap-dump safety)
632
+ // then clear the cache so new clients use the fresh transport.
633
+ for (const [, entry] of signingClientCache) {
634
+ if (typeof entry.signingClient.dispose === 'function') {
455
635
  try {
456
- v.signingClient.dispose();
636
+ entry.signingClient.dispose();
457
637
  }
458
638
  catch (_) { }
459
639
  }
460
- signingClientCache.delete(k);
461
640
  }
641
+ signingClientCache.clear();
642
+ }
643
+ const fp = keyFingerprint(String(privateKey));
644
+ const cacheKey = accountName + ':' + fp;
645
+ let cached = signingClientCache.get(cacheKey);
646
+ if (!cached) {
647
+ // Proactively evict stale entries for the same account (different fingerprint)
648
+ // so key rotation never leaves dead entries in the map.
649
+ const stalePrefix = accountName + ':';
650
+ for (const [k, v] of signingClientCache) {
651
+ if (k !== cacheKey && k.startsWith(stalePrefix)) {
652
+ if (typeof v.signingClient.dispose === 'function') {
653
+ try {
654
+ v.signingClient.dispose();
655
+ }
656
+ catch (_) { }
657
+ }
658
+ signingClientCache.delete(k);
659
+ }
660
+ }
661
+ pruneStaleSigningClients();
662
+ // Pre-convert WIF string → Buffer so dispose() can actually zero the bytes.
663
+ // The WIF string goes out of scope at function return and is GC'd (non-deterministic),
664
+ // but the Buffer passed to createSigningClient is filled with 0 by dispose() on eviction/shutdown.
665
+ const { createSigningClient, wifToBuffer } = require('./modules/bitshares-native');
666
+ const keyBuffer = wifToBuffer(String(privateKey));
667
+ const signingClient = createSigningClient(_nativeChainClient, accountName, keyBuffer);
668
+ cached = { signingClient, createdAt: Date.now() };
669
+ signingClientCache.set(cacheKey, cached);
670
+ }
671
+ const client = cached.signingClient.client;
672
+ await client.initPromise;
673
+ // The guard may have fired while the signing client fetched
674
+ // account/order metadata. Abort before the broadcast RPC so
675
+ // nothing is transmitted post-deadline — a zombie broadcast
676
+ // landing after the bot verified chain absence would duplicate
677
+ // the operation.
678
+ if (guard.isFired()) {
679
+ throw buildUncertainError(accountName, startedAt, 'inner broadcast deadline exceeded during signing client init');
462
680
  }
463
- pruneStaleSigningClients();
464
- // Pre-convert WIF string → Buffer so dispose() can actually zero the bytes.
465
- // The WIF string goes out of scope at function return and is GC'd (non-deterministic),
466
- // but the Buffer passed to createSigningClient is filled with 0 by dispose() on eviction/shutdown.
467
- const { createSigningClient, wifToBuffer } = require('./modules/bitshares-native');
468
- const keyBuffer = wifToBuffer(String(privateKey));
469
- const signingClient = createSigningClient(_nativeChainClient, accountName, keyBuffer);
470
- cached = { signingClient, createdAt: Date.now() };
471
- signingClientCache.set(cacheKey, cached);
681
+ phase = 'broadcast';
682
+ const result = await broadcastFn(client);
683
+ // Success — restore the full node list so other users of
684
+ // the shared client don't inherit the single-node pin.
685
+ _nativeChainClient.setNodes(_nativeNodeList.length > 0 ? _nativeNodeList : NODE_MANAGEMENT.DEFAULT_NODES);
686
+ return result;
472
687
  }
473
- const client = cached.signingClient.client;
474
- await client.initPromise;
475
- return await broadcastFn(client);
476
- }
477
- catch (err) {
478
- if (attempt === maxRetries)
688
+ catch (err) {
689
+ // Guard-fired aborts (deadline during connect/init) must
690
+ // propagate as uncertain immediately — they are never
691
+ // retryable, even during the connect phase, and a retry
692
+ // would just re-enter the same deadlined attempt.
693
+ if (err?.code === DAEMON_CODES.BROADCAST_DEADLINE || err?.uncertain === true) {
694
+ throw err;
695
+ }
696
+ // Connect-phase failures are always pre-transmit: no broadcast
697
+ // has been attempted yet. Broadcast-phase failures are only
698
+ // retryable when the RPC frame provably never reached the wire.
699
+ const cls = phase === 'connect' ? 'retryable' : classifyBroadcastFailure(err);
700
+ if (cls === 'retryable') {
701
+ lastErr = err;
702
+ const retryLabel = attempt < attemptsPerNode ? 'retrying same node' : 'exhausted; rotating to next best node';
703
+ debugLog(`Broadcast attempt ${attempt}/${attemptsPerNode} on ${candidate} failed pre-transmit (${getErrorMessage(err)}); ${retryLabel}`);
704
+ // Reset the transport so the next attempt reconnects
705
+ // cleanly against the pinned node.
706
+ try {
707
+ _nativeChainClient.disconnect();
708
+ }
709
+ catch (_) { }
710
+ continue;
711
+ }
712
+ if (cls === 'uncertain') {
713
+ // The transaction may have landed — never re-sign on ANY
714
+ // node. Report the typed uncertain reply so the bot's
715
+ // verify-before-retry machinery checks chain inclusion
716
+ // before any re-create.
717
+ throw buildUncertainError(accountName, startedAt, getErrorMessage(err));
718
+ }
719
+ // 'definite': the chain rejected the transaction or it could
720
+ // not be built — nothing landed; the error propagates as-is.
479
721
  throw err;
480
- debugLog(`Broadcast failed (attempt ${attempt}), reconnecting: ${getErrorMessage(err)}`);
481
- const staleKey = accountName + ':' + keyFingerprint(String(privateKey));
482
- const staleEntry = signingClientCache.get(staleKey);
483
- if (staleEntry && typeof staleEntry.signingClient.dispose === 'function') {
484
- staleEntry.signingClient.dispose();
485
- }
486
- signingClientCache.delete(staleKey);
487
- try {
488
- _nativeChainClient.disconnect();
489
722
  }
490
- catch (_) { }
491
723
  }
724
+ // All attempts on this candidate failed pre-transmit: nothing ever
725
+ // reached the chain via this node. Report the failure so the node
726
+ // health ledger counts it toward blacklisting, then rotate to the
727
+ // next best node (re-reading the health cache first).
728
+ exhaustedNodes.push(candidate);
729
+ daemonNodeHealth.reportFailure(candidate, getErrorMessage(lastErr) || 'pre-transmit broadcast failure');
730
+ candidates = buildCandidatePool();
731
+ }
732
+ // All candidates failed pre-transmit (or none were usable): nothing
733
+ // ever reached the chain.
734
+ if (!lastErr) {
735
+ lastErr = new Error('Broadcast not attempted: no usable node candidates (all blacklisted or unavailable)');
492
736
  }
737
+ throw lastErr;
493
738
  })();
739
+ // Expose the WORK promise (not the deadline-raced result) to the broadcast
740
+ // queue: serializeBroadcast chains the next queued broadcast on it, so a
741
+ // deadline-aborted zombie cannot overlap the next broadcast on the shared
742
+ // transport. The handler reads it back via opts.workRef.
743
+ if (opts.workRef && typeof opts.workRef === 'object') {
744
+ opts.workRef.work = work;
745
+ }
494
746
  try {
495
747
  return await Promise.race([work, deadlinePromise]);
496
748
  }
497
749
  finally {
498
- if (deadlineTimer)
499
- clearTimeout(deadlineTimer);
750
+ guard.clearTimer();
751
+ if (guard.isFired()) {
752
+ // The guard won while the broadcast may still be in flight —
753
+ // drop the connection so the next request starts clean. The
754
+ // transport abort also kills any in-flight connect handshake, so
755
+ // the zombie cannot resurrect a connection afterwards.
756
+ try {
757
+ _nativeChainClient.disconnect();
758
+ }
759
+ catch (_) { }
760
+ }
500
761
  // Prune stale signing clients on every broadcast, not just on cache
501
762
  // miss, so long-running daemons don't accumulate stale entries when
502
763
  // accounts always hit the cache (same WIF, no rotation).
@@ -525,6 +786,14 @@ function refreshNodeList() {
525
786
  }
526
787
  }
527
788
  }
789
+ /**
790
+ * Daemon-local node health ledger: blacklists persistently failing nodes so
791
+ * the broadcast path rotates away from them and the shared health cache is
792
+ * updated when a node hits the failure threshold. See
793
+ * modules/daemon_node_health.ts for semantics.
794
+ */
795
+ const { createNodeHealthLedger } = require('./modules/daemon_node_health');
796
+ const daemonNodeHealth = createNodeHealthLedger({ logger: daemonLogger });
528
797
  function getCredentialDaemonNodeRefreshIntervalMs(settings) {
529
798
  const configured = settings?.NODES?.credentialDaemonRefreshIntervalMs
530
799
  ?? settings?.NODES?.CREDENTIAL_DAEMON_NODE_REFRESH_INTERVAL_MS
@@ -720,9 +989,22 @@ async function initialize() {
720
989
  */
721
990
  function handleConnection(socket) {
722
991
  let buffer = '';
992
+ // Broadcast deadline guards for requests on this socket. Fired when the
993
+ // socket dies (bot outer timeout destroys its end, crash, restart): the
994
+ // queued/in-flight broadcast for a client that can no longer receive the
995
+ // reply must abort — otherwise it can land on chain after the bot
996
+ // verified chain absence and re-broadcast the same operation.
997
+ const activeGuards = new Set();
998
+ const abortSocketGuards = () => {
999
+ for (const guard of activeGuards) {
1000
+ guard.fire('client socket closed before broadcast reply');
1001
+ }
1002
+ activeGuards.clear();
1003
+ };
723
1004
  socket.setTimeout(TIMING.CREDENTIAL_DAEMON_SOCKET_TIMEOUT_MS);
724
1005
  socket.on('timeout', () => {
725
1006
  daemonLogger.debug?.('[credential-daemon] Socket timeout — client idle');
1007
+ abortSocketGuards();
726
1008
  socket.destroy();
727
1009
  });
728
1010
  socket.on('data', (data) => {
@@ -738,7 +1020,7 @@ function handleConnection(socket) {
738
1020
  buffer = lines.pop() || ''; // Keep incomplete line in buffer
739
1021
  for (const line of lines) {
740
1022
  if (line.trim()) {
741
- processRequest(line.trim(), socket);
1023
+ processRequest(line.trim(), socket, activeGuards);
742
1024
  }
743
1025
  }
744
1026
  }
@@ -747,12 +1029,17 @@ function handleConnection(socket) {
747
1029
  }
748
1030
  });
749
1031
  socket.on('end', () => {
1032
+ abortSocketGuards();
750
1033
  socket.destroy();
751
1034
  });
752
1035
  socket.on('error', (error) => {
753
1036
  daemonLogger.debug?.('[credential-daemon] Socket error: ' + getErrorMessage(error));
1037
+ abortSocketGuards();
754
1038
  socket.destroy();
755
1039
  });
1040
+ socket.on('close', () => {
1041
+ abortSocketGuards();
1042
+ });
756
1043
  }
757
1044
  /**
758
1045
  * Process incoming credential request from client.
@@ -762,7 +1049,7 @@ function handleConnection(socket) {
762
1049
  * @param {string} requestStr - JSON string with {type, accountName}
763
1050
  * @param {net.Socket} socket - Client socket to send response
764
1051
  */
765
- function processRequest(requestStr, socket) {
1052
+ function processRequest(requestStr, socket, activeGuards = new Set()) {
766
1053
  if (daemonShuttingDown)
767
1054
  return;
768
1055
  // The outer try/catch handles JSON parse errors and any synchronous throws.
@@ -862,9 +1149,20 @@ function processRequest(requestStr, socket) {
862
1149
  }
863
1150
  const privateKey = await loadCurrentPrivateKey(accountName);
864
1151
  const broadcastNodeUrl = typeof request.nodeUrl === 'string' ? request.nodeUrl : null;
1152
+ // Start the broadcast deadline NOW, before the
1153
+ // serializeBroadcast queue wait, so total daemon wall time
1154
+ // per request stays inside the bot's outer socket window.
1155
+ // If the deadline fires while queued (burst concurrency),
1156
+ // the queued broadcast aborts via guard.isFired() before
1157
+ // it starts — it can never land after the bot verified
1158
+ // chain absence and re-broadcast the same operation.
1159
+ const broadcastStartedAt = Date.now();
1160
+ const broadcastGuard = createBroadcastGuard(accountName, broadcastStartedAt, resolveInnerDeadlineMs());
1161
+ activeGuards.add(broadcastGuard);
1162
+ const broadcastWorkRef = { work: null };
865
1163
  let signResult;
866
1164
  try {
867
- signResult = await broadcastWithRetry(accountName, privateKey, (client) => client.broadcast(operation), broadcastNodeUrl);
1165
+ signResult = await serializeBroadcast(() => broadcastWithDeadline(accountName, privateKey, (client) => client.broadcast(operation), broadcastNodeUrl, { guard: broadcastGuard, startedAt: broadcastStartedAt, workRef: broadcastWorkRef }), () => broadcastWorkRef.work || Promise.resolve());
868
1166
  }
869
1167
  catch (broadcastErr) {
870
1168
  if (broadcastErr && broadcastErr.code === DAEMON_CODES.BROADCAST_DEADLINE) {
@@ -872,7 +1170,7 @@ function processRequest(requestStr, socket) {
872
1170
  event: 'sign_timeout',
873
1171
  accountName,
874
1172
  sessionId,
875
- nodeUrl: broadcastNodeUrl,
1173
+ nodeUrl: broadcastGuard.currentNode || broadcastNodeUrl,
876
1174
  opCount: 1,
877
1175
  opTypes: [operation && operation.op_name].filter(Boolean),
878
1176
  ageMs: broadcastErr.ageMs,
@@ -883,11 +1181,19 @@ function processRequest(requestStr, socket) {
883
1181
  });
884
1182
  // Tell the bot the chain state is uncertain so it
885
1183
  // can run the recovery path (read chain, match by
886
- // fingerprint, adopt or discard).
887
- return sendError(socket, 'chain status uncertain after inner deadline', DAEMON_CODES.BROADCAST_DEADLINE);
1184
+ // fingerprint, adopt or discard). Name the node
1185
+ // actually in play at the deadline (the bot's
1186
+ // preferred node may be innocent after daemon-side
1187
+ // rotation, and a queued deadline involves none).
1188
+ return sendError(socket, 'chain status uncertain after inner deadline', DAEMON_CODES.BROADCAST_DEADLINE, broadcastGuard.currentNode
1189
+ ? { nodeUrl: broadcastGuard.currentNode }
1190
+ : {});
888
1191
  }
889
1192
  throw broadcastErr;
890
1193
  }
1194
+ finally {
1195
+ activeGuards.delete(broadcastGuard);
1196
+ }
891
1197
  appendAuditLog({
892
1198
  event: 'sign_allowed',
893
1199
  accountName,
@@ -955,9 +1261,20 @@ function processRequest(requestStr, socket) {
955
1261
  }
956
1262
  const privateKey = await loadCurrentPrivateKey(accountName);
957
1263
  const broadcastNodeUrl = typeof request.nodeUrl === 'string' ? request.nodeUrl : null;
1264
+ // Start the broadcast deadline NOW, before the
1265
+ // serializeBroadcast queue wait, so total daemon wall time
1266
+ // per request stays inside the bot's outer socket window.
1267
+ // If the deadline fires while queued (burst concurrency),
1268
+ // the queued broadcast aborts via guard.isFired() before
1269
+ // it starts — it can never land after the bot verified
1270
+ // chain absence and re-broadcast the same operation.
1271
+ const broadcastStartedAt = Date.now();
1272
+ const broadcastGuard = createBroadcastGuard(accountName, broadcastStartedAt, resolveInnerDeadlineMs());
1273
+ activeGuards.add(broadcastGuard);
1274
+ const broadcastWorkRef = { work: null };
958
1275
  let signResult;
959
1276
  try {
960
- signResult = await broadcastWithRetry(accountName, privateKey, (client) => executeOperationsWithClient(client, operations), broadcastNodeUrl);
1277
+ signResult = await serializeBroadcast(() => broadcastWithDeadline(accountName, privateKey, (client) => executeOperationsWithClient(client, operations), broadcastNodeUrl, { guard: broadcastGuard, startedAt: broadcastStartedAt, workRef: broadcastWorkRef }), () => broadcastWorkRef.work || Promise.resolve());
961
1278
  }
962
1279
  catch (broadcastErr) {
963
1280
  if (broadcastErr && broadcastErr.code === DAEMON_CODES.BROADCAST_DEADLINE) {
@@ -965,7 +1282,7 @@ function processRequest(requestStr, socket) {
965
1282
  event: 'sign_timeout',
966
1283
  accountName,
967
1284
  sessionId,
968
- nodeUrl: broadcastNodeUrl,
1285
+ nodeUrl: broadcastGuard.currentNode || broadcastNodeUrl,
969
1286
  opCount: operations.length,
970
1287
  opTypes: operations.map((o) => o && o.op_name).filter(Boolean),
971
1288
  ageMs: broadcastErr.ageMs,
@@ -974,10 +1291,15 @@ function processRequest(requestStr, socket) {
974
1291
  : null,
975
1292
  timestamp: new Date().toISOString(),
976
1293
  });
977
- return sendError(socket, 'chain status uncertain after inner deadline', DAEMON_CODES.BROADCAST_DEADLINE);
1294
+ return sendError(socket, 'chain status uncertain after inner deadline', DAEMON_CODES.BROADCAST_DEADLINE, broadcastGuard.currentNode
1295
+ ? { nodeUrl: broadcastGuard.currentNode }
1296
+ : {});
978
1297
  }
979
1298
  throw broadcastErr;
980
1299
  }
1300
+ finally {
1301
+ activeGuards.delete(broadcastGuard);
1302
+ }
981
1303
  appendAuditLog({
982
1304
  event: 'sign_allowed',
983
1305
  accountName,
@@ -1017,12 +1339,14 @@ function sendSuccess(socket, data) {
1017
1339
  * @param {net.Socket} socket - Client socket
1018
1340
  * @param {string} message - Error message
1019
1341
  * @param {number} code - Error code
1342
+ * @param {Object} [extra] - Extra fields merged into the response (e.g. nodeUrl)
1020
1343
  */
1021
- function sendError(socket, message, code = null) {
1344
+ function sendError(socket, message, code = null, extra = {}) {
1022
1345
  const response = JSON.stringify({
1023
1346
  success: false,
1024
1347
  error: message,
1025
- ...(code ? { code } : {})
1348
+ ...(code ? { code } : {}),
1349
+ ...extra
1026
1350
  });
1027
1351
  socket.write(response + '\n');
1028
1352
  socket.end();