@orbinum/sdk 0.17.0 → 0.18.0

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.
package/dist/index.d.mts CHANGED
@@ -105,6 +105,18 @@ declare class SubstrateClient {
105
105
  private constructor();
106
106
  private _dynamicBuilder;
107
107
  private _extDecoder;
108
+ private _inflightTxCount;
109
+ /**
110
+ * `true` while any submitted transaction is still waiting for finalization.
111
+ * Connection managers use this to defer destroying the client — killing the
112
+ * WS mid-submit rejects the pending tx with "Client destroyed" even though
113
+ * it may still land on-chain.
114
+ *
115
+ * Only covers promise-based submits (`submit`, `submitUnsignedAndWatch`,
116
+ * `signAndSubmit`); observable-based `submitAndWatch` callers are not tracked.
117
+ */
118
+ get hasInflightTx(): boolean;
119
+ private trackTx;
108
120
  /**
109
121
  * Connects to the Orbinum node via WebSocket.
110
122
  * Throws if the node does not respond within `timeoutMs`.
@@ -285,14 +297,25 @@ declare class EvmClient {
285
297
  }): Promise<bigint>;
286
298
  /** Returns a transaction receipt by hash, or `null` if the transaction has not been mined yet. */
287
299
  getTransactionReceipt(txHash: string): Promise<Record<string, unknown> | null>;
300
+ /**
301
+ * Fetches a transaction by hash, or `null` when the node no longer knows it
302
+ * (never mined and evicted from the pool). Unlike `request`, a `null`
303
+ * result is a valid answer here, not an error.
304
+ */
305
+ getTransactionByHash(txHash: string): Promise<Record<string, unknown> | null>;
288
306
  /**
289
307
  * Polls `eth_getTransactionReceipt` until the transaction is included in a block.
290
308
  *
309
+ * After `timeoutMs`, the tx-pool is consulted: a tx no longer known to the
310
+ * node is reported as dropped (safe to retry), while a tx still in the pool
311
+ * gets an extended grace window (up to 4× `timeoutMs`) before a "still
312
+ * pending" error — it may confirm later, so callers must NOT blindly retry.
313
+ *
291
314
  * @param txHash - The transaction hash to wait for.
292
315
  * @param intervalMs - Polling interval in milliseconds (default: 500).
293
316
  * @param timeoutMs - Maximum time to wait in milliseconds (default: 60_000).
294
317
  * @returns The transaction receipt once mined.
295
- * @throws If the transaction is not mined within `timeoutMs` or if it reverted (`status == 0x0`).
318
+ * @throws If the transaction dropped, is still pending after the grace window, or reverted (`status == 0x0`).
296
319
  */
297
320
  waitForReceipt(txHash: string, intervalMs?: number, timeoutMs?: number): Promise<Record<string, unknown>>;
298
321
  }
@@ -1776,6 +1799,7 @@ declare class OrbinumClientProvider {
1776
1799
  private _reconnectTimer;
1777
1800
  private _stableTimer;
1778
1801
  private _reconnectAttempt;
1802
+ private _probeFailures;
1779
1803
  private _listeners;
1780
1804
  /** Creates a new provider with the given configuration. Does not connect automatically — call `connect()` to initiate. */
1781
1805
  constructor(config: ClientProviderConfig);
@@ -1806,6 +1830,21 @@ declare class OrbinumClientProvider {
1806
1830
  * On failure: destroys any orphaned client and transitions to `'disconnected'`.
1807
1831
  */
1808
1832
  private attemptConnect;
1833
+ /**
1834
+ * Consecutive failed probes required before the client is torn down. A
1835
+ * single missed probe is routine (throttled background tab, node busy
1836
+ * verifying a ZK proof, transient network blip) — destroying the client on
1837
+ * it rejects every in-flight request with "Client destroyed" even though
1838
+ * the tx may still land on-chain.
1839
+ */
1840
+ private static readonly PROBE_FAILURE_THRESHOLD;
1841
+ /**
1842
+ * With a tx awaiting finalization, tolerate more missed probes: an unsigned
1843
+ * private_transfer/unshield makes the node CPU-bound on proof verification,
1844
+ * which is exactly when probes time out — tearing down then kills the very
1845
+ * tx being processed.
1846
+ */
1847
+ private static readonly PROBE_FAILURE_THRESHOLD_INFLIGHT;
1809
1848
  /** Starts the periodic heartbeat loop. Replaces any existing timer. */
1810
1849
  private startHeartbeat;
1811
1850
  /** Clears the heartbeat interval timer if active. */
package/dist/index.d.ts CHANGED
@@ -105,6 +105,18 @@ declare class SubstrateClient {
105
105
  private constructor();
106
106
  private _dynamicBuilder;
107
107
  private _extDecoder;
108
+ private _inflightTxCount;
109
+ /**
110
+ * `true` while any submitted transaction is still waiting for finalization.
111
+ * Connection managers use this to defer destroying the client — killing the
112
+ * WS mid-submit rejects the pending tx with "Client destroyed" even though
113
+ * it may still land on-chain.
114
+ *
115
+ * Only covers promise-based submits (`submit`, `submitUnsignedAndWatch`,
116
+ * `signAndSubmit`); observable-based `submitAndWatch` callers are not tracked.
117
+ */
118
+ get hasInflightTx(): boolean;
119
+ private trackTx;
108
120
  /**
109
121
  * Connects to the Orbinum node via WebSocket.
110
122
  * Throws if the node does not respond within `timeoutMs`.
@@ -285,14 +297,25 @@ declare class EvmClient {
285
297
  }): Promise<bigint>;
286
298
  /** Returns a transaction receipt by hash, or `null` if the transaction has not been mined yet. */
287
299
  getTransactionReceipt(txHash: string): Promise<Record<string, unknown> | null>;
300
+ /**
301
+ * Fetches a transaction by hash, or `null` when the node no longer knows it
302
+ * (never mined and evicted from the pool). Unlike `request`, a `null`
303
+ * result is a valid answer here, not an error.
304
+ */
305
+ getTransactionByHash(txHash: string): Promise<Record<string, unknown> | null>;
288
306
  /**
289
307
  * Polls `eth_getTransactionReceipt` until the transaction is included in a block.
290
308
  *
309
+ * After `timeoutMs`, the tx-pool is consulted: a tx no longer known to the
310
+ * node is reported as dropped (safe to retry), while a tx still in the pool
311
+ * gets an extended grace window (up to 4× `timeoutMs`) before a "still
312
+ * pending" error — it may confirm later, so callers must NOT blindly retry.
313
+ *
291
314
  * @param txHash - The transaction hash to wait for.
292
315
  * @param intervalMs - Polling interval in milliseconds (default: 500).
293
316
  * @param timeoutMs - Maximum time to wait in milliseconds (default: 60_000).
294
317
  * @returns The transaction receipt once mined.
295
- * @throws If the transaction is not mined within `timeoutMs` or if it reverted (`status == 0x0`).
318
+ * @throws If the transaction dropped, is still pending after the grace window, or reverted (`status == 0x0`).
296
319
  */
297
320
  waitForReceipt(txHash: string, intervalMs?: number, timeoutMs?: number): Promise<Record<string, unknown>>;
298
321
  }
@@ -1776,6 +1799,7 @@ declare class OrbinumClientProvider {
1776
1799
  private _reconnectTimer;
1777
1800
  private _stableTimer;
1778
1801
  private _reconnectAttempt;
1802
+ private _probeFailures;
1779
1803
  private _listeners;
1780
1804
  /** Creates a new provider with the given configuration. Does not connect automatically — call `connect()` to initiate. */
1781
1805
  constructor(config: ClientProviderConfig);
@@ -1806,6 +1830,21 @@ declare class OrbinumClientProvider {
1806
1830
  * On failure: destroys any orphaned client and transitions to `'disconnected'`.
1807
1831
  */
1808
1832
  private attemptConnect;
1833
+ /**
1834
+ * Consecutive failed probes required before the client is torn down. A
1835
+ * single missed probe is routine (throttled background tab, node busy
1836
+ * verifying a ZK proof, transient network blip) — destroying the client on
1837
+ * it rejects every in-flight request with "Client destroyed" even though
1838
+ * the tx may still land on-chain.
1839
+ */
1840
+ private static readonly PROBE_FAILURE_THRESHOLD;
1841
+ /**
1842
+ * With a tx awaiting finalization, tolerate more missed probes: an unsigned
1843
+ * private_transfer/unshield makes the node CPU-bound on proof verification,
1844
+ * which is exactly when probes time out — tearing down then kills the very
1845
+ * tx being processed.
1846
+ */
1847
+ private static readonly PROBE_FAILURE_THRESHOLD_INFLIGHT;
1809
1848
  /** Starts the periodic heartbeat loop. Replaces any existing timer. */
1810
1849
  private startHeartbeat;
1811
1850
  /** Clears the heartbeat interval timer if active. */
package/dist/index.js CHANGED
@@ -225,6 +225,27 @@ var SubstrateClient = class _SubstrateClient {
225
225
  _httpUrl;
226
226
  _dynamicBuilder = null;
227
227
  _extDecoder = null;
228
+ _inflightTxCount = 0;
229
+ /**
230
+ * `true` while any submitted transaction is still waiting for finalization.
231
+ * Connection managers use this to defer destroying the client — killing the
232
+ * WS mid-submit rejects the pending tx with "Client destroyed" even though
233
+ * it may still land on-chain.
234
+ *
235
+ * Only covers promise-based submits (`submit`, `submitUnsignedAndWatch`,
236
+ * `signAndSubmit`); observable-based `submitAndWatch` callers are not tracked.
237
+ */
238
+ get hasInflightTx() {
239
+ return this._inflightTxCount > 0;
240
+ }
241
+ async trackTx(p) {
242
+ this._inflightTxCount++;
243
+ try {
244
+ return await p;
245
+ } finally {
246
+ this._inflightTxCount--;
247
+ }
248
+ }
228
249
  /**
229
250
  * Connects to the Orbinum node via WebSocket.
230
251
  * Throws if the node does not respond within `timeoutMs`.
@@ -437,7 +458,7 @@ var SubstrateClient = class _SubstrateClient {
437
458
  * Submits a pre-signed extrinsic (hex string) and waits for finalization.
438
459
  */
439
460
  async submit(signedHex) {
440
- return this._papi.submit(import_polkadot_api.Binary.fromHex(signedHex));
461
+ return this.trackTx(this._papi.submit(import_polkadot_api.Binary.fromHex(signedHex)));
441
462
  }
442
463
  /**
443
464
  * Submits a pre-signed extrinsic and returns an Observable of tx lifecycle events.
@@ -452,14 +473,14 @@ var SubstrateClient = class _SubstrateClient {
452
473
  * The bare tx bytes are produced by `tx.getBareTx()` from polkadot-api.
453
474
  */
454
475
  async submitUnsignedAndWatch(bareTx) {
455
- return this._papi.submit(bareTx);
476
+ return this.trackTx(this._papi.submit(bareTx));
456
477
  }
457
478
  /**
458
479
  * Convenience: wrap raw call bytes and sign+submit in one step.
459
480
  */
460
481
  async signAndSubmit(callData, signer) {
461
482
  const tx = await this.txFromCallData(callData);
462
- return tx.signAndSubmit(signer);
483
+ return this.trackTx(tx.signAndSubmit(signer));
463
484
  }
464
485
  /** Closes the WebSocket connection. */
465
486
  destroy() {
@@ -716,17 +737,46 @@ var EvmClient = class {
716
737
  }
717
738
  return json.result ?? null;
718
739
  }
740
+ /**
741
+ * Fetches a transaction by hash, or `null` when the node no longer knows it
742
+ * (never mined and evicted from the pool). Unlike `request`, a `null`
743
+ * result is a valid answer here, not an error.
744
+ */
745
+ async getTransactionByHash(txHash) {
746
+ const res = await postJsonWithRetry(
747
+ this.rpcUrl,
748
+ JSON.stringify({
749
+ id: 1,
750
+ jsonrpc: "2.0",
751
+ method: "eth_getTransactionByHash",
752
+ params: [txHash]
753
+ })
754
+ );
755
+ if (!res.ok) throw new Error(`EVM HTTP ${res.status}: ${res.statusText}`);
756
+ const json = await res.json();
757
+ if (json.error) {
758
+ throw new Error(`EVM RPC [${json.error.code}]: ${json.error.message}`);
759
+ }
760
+ return json.result ?? null;
761
+ }
719
762
  /**
720
763
  * Polls `eth_getTransactionReceipt` until the transaction is included in a block.
721
764
  *
765
+ * After `timeoutMs`, the tx-pool is consulted: a tx no longer known to the
766
+ * node is reported as dropped (safe to retry), while a tx still in the pool
767
+ * gets an extended grace window (up to 4× `timeoutMs`) before a "still
768
+ * pending" error — it may confirm later, so callers must NOT blindly retry.
769
+ *
722
770
  * @param txHash - The transaction hash to wait for.
723
771
  * @param intervalMs - Polling interval in milliseconds (default: 500).
724
772
  * @param timeoutMs - Maximum time to wait in milliseconds (default: 60_000).
725
773
  * @returns The transaction receipt once mined.
726
- * @throws If the transaction is not mined within `timeoutMs` or if it reverted (`status == 0x0`).
774
+ * @throws If the transaction dropped, is still pending after the grace window, or reverted (`status == 0x0`).
727
775
  */
728
776
  async waitForReceipt(txHash, intervalMs = 500, timeoutMs = 6e4) {
729
- const deadline = Date.now() + timeoutMs;
777
+ const start = Date.now();
778
+ const hardDeadline = start + timeoutMs * 4;
779
+ let deadline = start + timeoutMs;
730
780
  while (Date.now() < deadline) {
731
781
  const receipt = await this.getTransactionReceipt(txHash);
732
782
  if (receipt !== null) {
@@ -737,10 +787,7 @@ var EvmClient = class {
737
787
  if (!revertDetail) {
738
788
  try {
739
789
  const blockParam = receipt["blockNumber"] ?? "latest";
740
- const rawTx = await this.request(
741
- "eth_getTransactionByHash",
742
- [txHash]
743
- ).catch(() => null);
790
+ const rawTx = await this.getTransactionByHash(txHash).catch(() => null);
744
791
  if (rawTx) {
745
792
  const calldata = rawTx["input"] ?? rawTx["data"];
746
793
  if (calldata) {
@@ -763,8 +810,19 @@ var EvmClient = class {
763
810
  return receipt;
764
811
  }
765
812
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
813
+ if (Date.now() >= deadline && Date.now() < hardDeadline) {
814
+ const known = await this.getTransactionByHash(txHash).catch(() => void 0);
815
+ if (known === null) {
816
+ throw new Error(
817
+ `Transaction dropped from the tx pool (not mined within ${Date.now() - start}ms): ${txHash}`
818
+ );
819
+ }
820
+ deadline = Math.min(deadline + timeoutMs, hardDeadline);
821
+ }
766
822
  }
767
- throw new Error(`Transaction not mined within ${timeoutMs}ms: ${txHash}`);
823
+ throw new Error(
824
+ `Transaction still pending after ${Date.now() - start}ms: ${txHash} \u2014 it may still confirm; check the hash on the explorer before retrying`
825
+ );
768
826
  }
769
827
  };
770
828
 
@@ -3368,7 +3426,7 @@ var DEFAULT_HEARTBEAT_TIMEOUT_MS = 4e3;
3368
3426
  var DEFAULT_RECONNECT_BASE_MS = 3e3;
3369
3427
  var DEFAULT_RECONNECT_MAX_MS = 3e4;
3370
3428
  var DEFAULT_STABLE_AFTER_MS = 1e4;
3371
- var OrbinumClientProvider = class {
3429
+ var OrbinumClientProvider = class _OrbinumClientProvider {
3372
3430
  config;
3373
3431
  connectTimeoutMs;
3374
3432
  heartbeatIntervalMs;
@@ -3385,6 +3443,7 @@ var OrbinumClientProvider = class {
3385
3443
  _reconnectTimer = null;
3386
3444
  _stableTimer = null;
3387
3445
  _reconnectAttempt = 0;
3446
+ _probeFailures = 0;
3388
3447
  // ─── Events ─────────────────────────────────────────────────────────────
3389
3448
  _listeners = /* @__PURE__ */ new Set();
3390
3449
  /** Creates a new provider with the given configuration. Does not connect automatically — call `connect()` to initiate. */
@@ -3517,13 +3576,36 @@ var OrbinumClientProvider = class {
3517
3576
  }
3518
3577
  }
3519
3578
  // ─── Heartbeat ──────────────────────────────────────────────────────────
3579
+ /**
3580
+ * Consecutive failed probes required before the client is torn down. A
3581
+ * single missed probe is routine (throttled background tab, node busy
3582
+ * verifying a ZK proof, transient network blip) — destroying the client on
3583
+ * it rejects every in-flight request with "Client destroyed" even though
3584
+ * the tx may still land on-chain.
3585
+ */
3586
+ static PROBE_FAILURE_THRESHOLD = 2;
3587
+ /**
3588
+ * With a tx awaiting finalization, tolerate more missed probes: an unsigned
3589
+ * private_transfer/unshield makes the node CPU-bound on proof verification,
3590
+ * which is exactly when probes time out — tearing down then kills the very
3591
+ * tx being processed.
3592
+ */
3593
+ static PROBE_FAILURE_THRESHOLD_INFLIGHT = 6;
3520
3594
  /** Starts the periodic heartbeat loop. Replaces any existing timer. */
3521
3595
  startHeartbeat() {
3522
3596
  this.stopHeartbeat();
3597
+ this._probeFailures = 0;
3523
3598
  this._heartbeatTimer = setInterval(async () => {
3524
3599
  if (this._status !== "connected" || !this._orbinumClient) return;
3525
3600
  const alive = await this.probe();
3526
- if (!alive && this._status === "connected") {
3601
+ if (this._status !== "connected") return;
3602
+ if (alive) {
3603
+ this._probeFailures = 0;
3604
+ return;
3605
+ }
3606
+ this._probeFailures++;
3607
+ const threshold = this._orbinumClient?.substrate.hasInflightTx ? _OrbinumClientProvider.PROBE_FAILURE_THRESHOLD_INFLIGHT : _OrbinumClientProvider.PROBE_FAILURE_THRESHOLD;
3608
+ if (this._probeFailures >= threshold) {
3527
3609
  this.setStatus("disconnected", "Node is unreachable");
3528
3610
  this.teardownClient();
3529
3611
  this.scheduleReconnect();
package/dist/index.mjs CHANGED
@@ -90,6 +90,27 @@ var SubstrateClient = class _SubstrateClient {
90
90
  _httpUrl;
91
91
  _dynamicBuilder = null;
92
92
  _extDecoder = null;
93
+ _inflightTxCount = 0;
94
+ /**
95
+ * `true` while any submitted transaction is still waiting for finalization.
96
+ * Connection managers use this to defer destroying the client — killing the
97
+ * WS mid-submit rejects the pending tx with "Client destroyed" even though
98
+ * it may still land on-chain.
99
+ *
100
+ * Only covers promise-based submits (`submit`, `submitUnsignedAndWatch`,
101
+ * `signAndSubmit`); observable-based `submitAndWatch` callers are not tracked.
102
+ */
103
+ get hasInflightTx() {
104
+ return this._inflightTxCount > 0;
105
+ }
106
+ async trackTx(p) {
107
+ this._inflightTxCount++;
108
+ try {
109
+ return await p;
110
+ } finally {
111
+ this._inflightTxCount--;
112
+ }
113
+ }
93
114
  /**
94
115
  * Connects to the Orbinum node via WebSocket.
95
116
  * Throws if the node does not respond within `timeoutMs`.
@@ -302,7 +323,7 @@ var SubstrateClient = class _SubstrateClient {
302
323
  * Submits a pre-signed extrinsic (hex string) and waits for finalization.
303
324
  */
304
325
  async submit(signedHex) {
305
- return this._papi.submit(Binary.fromHex(signedHex));
326
+ return this.trackTx(this._papi.submit(Binary.fromHex(signedHex)));
306
327
  }
307
328
  /**
308
329
  * Submits a pre-signed extrinsic and returns an Observable of tx lifecycle events.
@@ -317,14 +338,14 @@ var SubstrateClient = class _SubstrateClient {
317
338
  * The bare tx bytes are produced by `tx.getBareTx()` from polkadot-api.
318
339
  */
319
340
  async submitUnsignedAndWatch(bareTx) {
320
- return this._papi.submit(bareTx);
341
+ return this.trackTx(this._papi.submit(bareTx));
321
342
  }
322
343
  /**
323
344
  * Convenience: wrap raw call bytes and sign+submit in one step.
324
345
  */
325
346
  async signAndSubmit(callData, signer) {
326
347
  const tx = await this.txFromCallData(callData);
327
- return tx.signAndSubmit(signer);
348
+ return this.trackTx(tx.signAndSubmit(signer));
328
349
  }
329
350
  /** Closes the WebSocket connection. */
330
351
  destroy() {
@@ -581,17 +602,46 @@ var EvmClient = class {
581
602
  }
582
603
  return json.result ?? null;
583
604
  }
605
+ /**
606
+ * Fetches a transaction by hash, or `null` when the node no longer knows it
607
+ * (never mined and evicted from the pool). Unlike `request`, a `null`
608
+ * result is a valid answer here, not an error.
609
+ */
610
+ async getTransactionByHash(txHash) {
611
+ const res = await postJsonWithRetry(
612
+ this.rpcUrl,
613
+ JSON.stringify({
614
+ id: 1,
615
+ jsonrpc: "2.0",
616
+ method: "eth_getTransactionByHash",
617
+ params: [txHash]
618
+ })
619
+ );
620
+ if (!res.ok) throw new Error(`EVM HTTP ${res.status}: ${res.statusText}`);
621
+ const json = await res.json();
622
+ if (json.error) {
623
+ throw new Error(`EVM RPC [${json.error.code}]: ${json.error.message}`);
624
+ }
625
+ return json.result ?? null;
626
+ }
584
627
  /**
585
628
  * Polls `eth_getTransactionReceipt` until the transaction is included in a block.
586
629
  *
630
+ * After `timeoutMs`, the tx-pool is consulted: a tx no longer known to the
631
+ * node is reported as dropped (safe to retry), while a tx still in the pool
632
+ * gets an extended grace window (up to 4× `timeoutMs`) before a "still
633
+ * pending" error — it may confirm later, so callers must NOT blindly retry.
634
+ *
587
635
  * @param txHash - The transaction hash to wait for.
588
636
  * @param intervalMs - Polling interval in milliseconds (default: 500).
589
637
  * @param timeoutMs - Maximum time to wait in milliseconds (default: 60_000).
590
638
  * @returns The transaction receipt once mined.
591
- * @throws If the transaction is not mined within `timeoutMs` or if it reverted (`status == 0x0`).
639
+ * @throws If the transaction dropped, is still pending after the grace window, or reverted (`status == 0x0`).
592
640
  */
593
641
  async waitForReceipt(txHash, intervalMs = 500, timeoutMs = 6e4) {
594
- const deadline = Date.now() + timeoutMs;
642
+ const start = Date.now();
643
+ const hardDeadline = start + timeoutMs * 4;
644
+ let deadline = start + timeoutMs;
595
645
  while (Date.now() < deadline) {
596
646
  const receipt = await this.getTransactionReceipt(txHash);
597
647
  if (receipt !== null) {
@@ -602,10 +652,7 @@ var EvmClient = class {
602
652
  if (!revertDetail) {
603
653
  try {
604
654
  const blockParam = receipt["blockNumber"] ?? "latest";
605
- const rawTx = await this.request(
606
- "eth_getTransactionByHash",
607
- [txHash]
608
- ).catch(() => null);
655
+ const rawTx = await this.getTransactionByHash(txHash).catch(() => null);
609
656
  if (rawTx) {
610
657
  const calldata = rawTx["input"] ?? rawTx["data"];
611
658
  if (calldata) {
@@ -628,8 +675,19 @@ var EvmClient = class {
628
675
  return receipt;
629
676
  }
630
677
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
678
+ if (Date.now() >= deadline && Date.now() < hardDeadline) {
679
+ const known = await this.getTransactionByHash(txHash).catch(() => void 0);
680
+ if (known === null) {
681
+ throw new Error(
682
+ `Transaction dropped from the tx pool (not mined within ${Date.now() - start}ms): ${txHash}`
683
+ );
684
+ }
685
+ deadline = Math.min(deadline + timeoutMs, hardDeadline);
686
+ }
631
687
  }
632
- throw new Error(`Transaction not mined within ${timeoutMs}ms: ${txHash}`);
688
+ throw new Error(
689
+ `Transaction still pending after ${Date.now() - start}ms: ${txHash} \u2014 it may still confirm; check the hash on the explorer before retrying`
690
+ );
633
691
  }
634
692
  };
635
693
 
@@ -3236,7 +3294,7 @@ var DEFAULT_HEARTBEAT_TIMEOUT_MS = 4e3;
3236
3294
  var DEFAULT_RECONNECT_BASE_MS = 3e3;
3237
3295
  var DEFAULT_RECONNECT_MAX_MS = 3e4;
3238
3296
  var DEFAULT_STABLE_AFTER_MS = 1e4;
3239
- var OrbinumClientProvider = class {
3297
+ var OrbinumClientProvider = class _OrbinumClientProvider {
3240
3298
  config;
3241
3299
  connectTimeoutMs;
3242
3300
  heartbeatIntervalMs;
@@ -3253,6 +3311,7 @@ var OrbinumClientProvider = class {
3253
3311
  _reconnectTimer = null;
3254
3312
  _stableTimer = null;
3255
3313
  _reconnectAttempt = 0;
3314
+ _probeFailures = 0;
3256
3315
  // ─── Events ─────────────────────────────────────────────────────────────
3257
3316
  _listeners = /* @__PURE__ */ new Set();
3258
3317
  /** Creates a new provider with the given configuration. Does not connect automatically — call `connect()` to initiate. */
@@ -3385,13 +3444,36 @@ var OrbinumClientProvider = class {
3385
3444
  }
3386
3445
  }
3387
3446
  // ─── Heartbeat ──────────────────────────────────────────────────────────
3447
+ /**
3448
+ * Consecutive failed probes required before the client is torn down. A
3449
+ * single missed probe is routine (throttled background tab, node busy
3450
+ * verifying a ZK proof, transient network blip) — destroying the client on
3451
+ * it rejects every in-flight request with "Client destroyed" even though
3452
+ * the tx may still land on-chain.
3453
+ */
3454
+ static PROBE_FAILURE_THRESHOLD = 2;
3455
+ /**
3456
+ * With a tx awaiting finalization, tolerate more missed probes: an unsigned
3457
+ * private_transfer/unshield makes the node CPU-bound on proof verification,
3458
+ * which is exactly when probes time out — tearing down then kills the very
3459
+ * tx being processed.
3460
+ */
3461
+ static PROBE_FAILURE_THRESHOLD_INFLIGHT = 6;
3388
3462
  /** Starts the periodic heartbeat loop. Replaces any existing timer. */
3389
3463
  startHeartbeat() {
3390
3464
  this.stopHeartbeat();
3465
+ this._probeFailures = 0;
3391
3466
  this._heartbeatTimer = setInterval(async () => {
3392
3467
  if (this._status !== "connected" || !this._orbinumClient) return;
3393
3468
  const alive = await this.probe();
3394
- if (!alive && this._status === "connected") {
3469
+ if (this._status !== "connected") return;
3470
+ if (alive) {
3471
+ this._probeFailures = 0;
3472
+ return;
3473
+ }
3474
+ this._probeFailures++;
3475
+ const threshold = this._orbinumClient?.substrate.hasInflightTx ? _OrbinumClientProvider.PROBE_FAILURE_THRESHOLD_INFLIGHT : _OrbinumClientProvider.PROBE_FAILURE_THRESHOLD;
3476
+ if (this._probeFailures >= threshold) {
3395
3477
  this.setStatus("disconnected", "Node is unreachable");
3396
3478
  this.teardownClient();
3397
3479
  this.scheduleReconnect();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbinum/sdk",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Official TypeScript SDK for Orbinum.",
5
5
  "author": "Orbinum",
6
6
  "license": "MIT",