@arkade-os/sdk 0.4.21 → 0.4.23

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 (80) hide show
  1. package/README.md +95 -12
  2. package/dist/cjs/contracts/arkcontract.js +2 -1
  3. package/dist/cjs/contracts/contractWatcher.js +16 -18
  4. package/dist/cjs/identity/descriptor.js +75 -4
  5. package/dist/cjs/identity/hdCapableIdentity.js +2 -0
  6. package/dist/cjs/identity/seedIdentity.js +225 -103
  7. package/dist/cjs/identity/serialize.js +5 -0
  8. package/dist/cjs/identity/staticDescriptorProvider.js +1 -1
  9. package/dist/cjs/index.js +12 -3
  10. package/dist/cjs/providers/electrum.js +285 -79
  11. package/dist/cjs/providers/expoIndexer.js +1 -1
  12. package/dist/cjs/providers/indexer.js +2 -2
  13. package/dist/cjs/providers/onchain.js +9 -3
  14. package/dist/cjs/repositories/migrations/walletRepositoryImpl.js +6 -2
  15. package/dist/cjs/repositories/realm/walletRepository.js +2 -2
  16. package/dist/cjs/repositories/serialization.js +34 -1
  17. package/dist/cjs/repositories/sqlite/walletRepository.js +4 -2
  18. package/dist/cjs/script/address.js +2 -1
  19. package/dist/cjs/utils/transactionHistory.js +4 -4
  20. package/dist/cjs/wallet/asset-manager.js +18 -18
  21. package/dist/cjs/wallet/asset.js +10 -8
  22. package/dist/cjs/wallet/delegator.js +29 -20
  23. package/dist/cjs/wallet/hdDescriptorProvider.js +159 -0
  24. package/dist/cjs/wallet/index.js +5 -1
  25. package/dist/cjs/wallet/onchain.js +2 -1
  26. package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +4 -2
  27. package/dist/cjs/wallet/serviceWorker/wallet.js +5 -4
  28. package/dist/cjs/wallet/validation.js +2 -3
  29. package/dist/cjs/wallet/vtxo-manager.js +7 -5
  30. package/dist/cjs/wallet/wallet.js +13 -14
  31. package/dist/esm/contracts/arkcontract.js +2 -1
  32. package/dist/esm/contracts/contractWatcher.js +14 -16
  33. package/dist/esm/identity/descriptor.js +74 -5
  34. package/dist/esm/identity/hdCapableIdentity.js +1 -0
  35. package/dist/esm/identity/seedIdentity.js +225 -103
  36. package/dist/esm/identity/serialize.js +5 -0
  37. package/dist/esm/identity/staticDescriptorProvider.js +1 -1
  38. package/dist/esm/index.js +7 -4
  39. package/dist/esm/providers/electrum.js +284 -78
  40. package/dist/esm/providers/expoIndexer.js +1 -1
  41. package/dist/esm/providers/indexer.js +2 -2
  42. package/dist/esm/providers/onchain.js +9 -3
  43. package/dist/esm/repositories/migrations/walletRepositoryImpl.js +6 -2
  44. package/dist/esm/repositories/realm/walletRepository.js +3 -3
  45. package/dist/esm/repositories/serialization.js +27 -0
  46. package/dist/esm/repositories/sqlite/walletRepository.js +5 -3
  47. package/dist/esm/script/address.js +2 -1
  48. package/dist/esm/utils/transactionHistory.js +4 -4
  49. package/dist/esm/wallet/asset-manager.js +18 -18
  50. package/dist/esm/wallet/asset.js +10 -8
  51. package/dist/esm/wallet/delegator.js +29 -20
  52. package/dist/esm/wallet/hdDescriptorProvider.js +155 -0
  53. package/dist/esm/wallet/index.js +4 -0
  54. package/dist/esm/wallet/onchain.js +2 -1
  55. package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +4 -2
  56. package/dist/esm/wallet/serviceWorker/wallet.js +5 -4
  57. package/dist/esm/wallet/validation.js +2 -3
  58. package/dist/esm/wallet/vtxo-manager.js +7 -5
  59. package/dist/esm/wallet/wallet.js +12 -14
  60. package/dist/types/contracts/arkcontract.d.ts +1 -1
  61. package/dist/types/contracts/types.d.ts +5 -5
  62. package/dist/types/identity/descriptor.d.ts +26 -0
  63. package/dist/types/identity/descriptorProvider.d.ts +11 -4
  64. package/dist/types/identity/hdCapableIdentity.d.ts +44 -0
  65. package/dist/types/identity/index.d.ts +1 -0
  66. package/dist/types/identity/seedIdentity.d.ts +113 -29
  67. package/dist/types/identity/serialize.d.ts +12 -0
  68. package/dist/types/identity/staticDescriptorProvider.d.ts +1 -1
  69. package/dist/types/index.d.ts +6 -3
  70. package/dist/types/providers/electrum.d.ts +115 -15
  71. package/dist/types/providers/onchain.d.ts +6 -0
  72. package/dist/types/repositories/serialization.d.ts +26 -2
  73. package/dist/types/script/address.d.ts +1 -1
  74. package/dist/types/wallet/delegator.d.ts +8 -3
  75. package/dist/types/wallet/hdDescriptorProvider.d.ts +93 -0
  76. package/dist/types/wallet/index.d.ts +19 -10
  77. package/dist/types/wallet/onchain.d.ts +1 -1
  78. package/dist/types/wallet/serviceWorker/wallet.d.ts +1 -1
  79. package/dist/types/wallet/wallet.d.ts +4 -1
  80. package/package.json +4 -4
@@ -1,9 +1,53 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ElectrumOnchainProvider = exports.WsElectrumChainSource = void 0;
3
+ exports.ElectrumOnchainProvider = exports.WsElectrumChainSource = exports.ELECTRUM_TCP_HOST = exports.ELECTRUM_WS_URL = void 0;
4
4
  const btc_signer_1 = require("@scure/btc-signer");
5
5
  const utils_js_1 = require("@scure/btc-signer/utils.js");
6
6
  const base_1 = require("@scure/base");
7
+ /**
8
+ * Default WebSocket Electrum endpoints. Mainnet, mutinynet, and signet
9
+ * point at Ark Labs–operated Fulcrum 2.1 deployments (which support
10
+ * `blockchain.transaction.broadcast_package` for atomic 1P1C TRUC
11
+ * relay; see `ElectrumOnchainProvider.broadcastTransaction`). Testnet
12
+ * defaults to Blockstream's public Fulcrum because Ark doesn't host
13
+ * it. Regtest assumes the `electrum-ws` websocat bridge from
14
+ * `vulpemventures/nigiri`.
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * import { ElectrumWS } from "ws-electrumx-client";
19
+ * import { ELECTRUM_WS_URL, ElectrumOnchainProvider, networks } from "@arkade-os/sdk";
20
+ *
21
+ * const ws = new ElectrumWS(ELECTRUM_WS_URL.bitcoin);
22
+ * const provider = new ElectrumOnchainProvider(ws, networks.bitcoin);
23
+ * ```
24
+ */
25
+ exports.ELECTRUM_WS_URL = {
26
+ bitcoin: "wss://electrum.arkade.sh",
27
+ testnet: "wss://electrum.blockstream.info:60004",
28
+ signet: "wss://electrum.signet.arkade.sh",
29
+ mutinynet: "wss://electrum.mutinynet.arkade.sh",
30
+ regtest: "ws://localhost:50003",
31
+ };
32
+ /**
33
+ * Hostnames for Electrum endpoints reachable over raw TCP. Provided as
34
+ * a reference for Node-side consumers — the SDK's
35
+ * {@link ElectrumOnchainProvider} only speaks WebSocket because it has
36
+ * to run in browsers, so this map is informational only and not
37
+ * consumed by any built-in provider.
38
+ *
39
+ * Public Ark Labs Fulcrum instances expose:
40
+ * - port 50001 — plain TCP (Electrum protocol)
41
+ * - port 50002 — TCP + TLS (Electrum protocol)
42
+ * - port 50003 — WebSocket (Electrum-over-WS, see {@link ELECTRUM_WS_URL})
43
+ */
44
+ exports.ELECTRUM_TCP_HOST = {
45
+ bitcoin: "electrum.arkade.sh",
46
+ testnet: null,
47
+ signet: "electrum.signet.arkade.sh",
48
+ mutinynet: "electrum.mutinynet.arkade.sh",
49
+ regtest: "localhost",
50
+ };
7
51
  // Electrum protocol method names
8
52
  const BroadcastTransaction = "blockchain.transaction.broadcast";
9
53
  const BroadcastPackageMethod = "blockchain.transaction.broadcast_package";
@@ -11,6 +55,7 @@ const EstimateFee = "blockchain.estimatefee";
11
55
  const GetBlockHeader = "blockchain.block.header";
12
56
  const GetHistoryMethod = "blockchain.scripthash.get_history";
13
57
  const GetTransactionMethod = "blockchain.transaction.get";
58
+ const GetTransactionMerkleMethod = "blockchain.transaction.get_merkle";
14
59
  const SubscribeStatusMethod = "blockchain.scripthash";
15
60
  const SubscribeHeadersMethod = "blockchain.headers";
16
61
  const GetRelayFeeMethod = "blockchain.relayfee";
@@ -69,6 +114,35 @@ class WsElectrumChainSource {
69
114
  this.cachedTip = null;
70
115
  this.headersSubscribePromise = null;
71
116
  }
117
+ /**
118
+ * Send N requests in parallel and aggregate the results, replacement
119
+ * for `ws.batchRequest`. The library's batchRequest is implemented as
120
+ * `Promise.all` over individual request promises — when one element
121
+ * rejects, the others remain pending. When their (often error)
122
+ * responses arrive later, the library rejects them too, and nobody is
123
+ * awaiting them: the rejections become unhandled and crash the test
124
+ * runner / pollute production logs.
125
+ *
126
+ * `safeBatchRequest` issues each request through `ws.request` (so each
127
+ * has its own request-promise lifecycle), waits for all of them via
128
+ * `Promise.allSettled` (every promise gets an explicit handler), and
129
+ * then surfaces the first error if any failed. Same wall-clock cost
130
+ * as the library's batch (parallel send), no orphan rejections.
131
+ *
132
+ * Use this in place of `ws.batchRequest` for any call where one or
133
+ * more elements may legitimately error (e.g. electrs index lag
134
+ * surfacing as `missingheight` for a subset of heights/txids).
135
+ */
136
+ async safeBatchRequest(requests) {
137
+ if (requests.length === 0)
138
+ return [];
139
+ const settled = await Promise.allSettled(requests.map((req) => this.ws.request(req.method, ...req.params)));
140
+ for (const r of settled) {
141
+ if (r.status === "rejected")
142
+ throw r.reason;
143
+ }
144
+ return settled.map((r) => r.value);
145
+ }
72
146
  async fetchTransactions(txids) {
73
147
  const requests = txids.map((txid) => ({
74
148
  method: GetTransactionMethod,
@@ -76,7 +150,7 @@ class WsElectrumChainSource {
76
150
  }));
77
151
  for (let i = 0; i < MAX_FETCH_TRANSACTIONS_ATTEMPTS; i++) {
78
152
  try {
79
- const responses = await this.ws.batchRequest(...requests);
153
+ const responses = await this.safeBatchRequest(requests);
80
154
  return responses.map((hexStr, i) => ({
81
155
  txID: txids[i],
82
156
  hex: hexStr,
@@ -104,7 +178,40 @@ class WsElectrumChainSource {
104
178
  method: GetTransactionMethod,
105
179
  params: [txid, true],
106
180
  }));
107
- return this.ws.batchRequest(...requests);
181
+ return this.safeBatchRequest(requests);
182
+ }
183
+ /**
184
+ * Look up the block height of a confirmed transaction without relying
185
+ * on the verbose-tx endpoint. `blockchain.transaction.get_merkle` is
186
+ * part of the standard SPV protocol and is supported by both Fulcrum
187
+ * and electrs (whereas `blockchain.transaction.get` with verbose=true
188
+ * is Fulcrum-only). Returns `null` when the tx is in the mempool —
189
+ * electrs in that case rejects with a "not yet in a block" error.
190
+ */
191
+ async fetchTxMerkle(txid) {
192
+ let result;
193
+ try {
194
+ result = await this.ws.request(GetTransactionMerkleMethod, txid);
195
+ }
196
+ catch (err) {
197
+ // electrs/Fulcrum raise specific errors when the tx isn't yet in
198
+ // a block — either "not in a block" wording, or "missingheight"
199
+ // when get_merkle hits the same index-lag race that block.header
200
+ // hits (the tx is reportedly confirmed but the height isn't
201
+ // queryable yet). Map both to mempool/unknown; everything else
202
+ // (auth failure, network outage, malformed response) must surface
203
+ // so callers can fail rather than silently treat the tx as
204
+ // unconfirmed forever.
205
+ if (isTxNotInBlockError(err) || isMissingHeightError(err))
206
+ return null;
207
+ throw err;
208
+ }
209
+ if (!result ||
210
+ typeof result.block_height !== "number" ||
211
+ result.block_height <= 0) {
212
+ return null;
213
+ }
214
+ return { blockHeight: result.block_height };
108
215
  }
109
216
  async unsubscribeScriptStatus(script) {
110
217
  await this.ws
@@ -121,18 +228,17 @@ class WsElectrumChainSource {
121
228
  }
122
229
  async fetchHistories(scripts) {
123
230
  const scriptsHashes = scripts.map((s) => toScriptHash(s));
124
- const responses = await this.ws.batchRequest(...scriptsHashes.map((s) => ({
231
+ return this.safeBatchRequest(scriptsHashes.map((s) => ({
125
232
  method: GetHistoryMethod,
126
233
  params: [s],
127
234
  })));
128
- return responses;
129
235
  }
130
236
  async fetchHistory(script) {
131
237
  const scriptHash = toScriptHash(script);
132
238
  return this.ws.request(GetHistoryMethod, scriptHash);
133
239
  }
134
240
  async fetchBlockHeaders(heights) {
135
- const responses = await this.ws.batchRequest(...heights.map((h) => ({ method: GetBlockHeader, params: [h] })));
241
+ const responses = await this.safeBatchRequest(heights.map((h) => ({ method: GetBlockHeader, params: [h] })));
136
242
  return responses.map((hexStr, i) => ({
137
243
  height: heights[i],
138
244
  hex: hexStr,
@@ -287,19 +393,45 @@ class WsElectrumChainSource {
287
393
  }
288
394
  exports.WsElectrumChainSource = WsElectrumChainSource;
289
395
  /**
290
- * Electrum-based implementation of the OnchainProvider interface.
291
- * Replaces esplora polling with electrum subscriptions where possible.
396
+ * Electrum-based implementation of the {@link OnchainProvider} interface.
292
397
  *
293
- * @example
398
+ * Built around the subset of the Electrum protocol that both **Fulcrum**
399
+ * and **electrs** support — listunspent, get_history, transaction.get
400
+ * (non-verbose), transaction.get_merkle, block.header,
401
+ * headers.subscribe, scripthash.subscribe, estimatefee, relayfee, and
402
+ * broadcast. The verbose form of `transaction.get` is **not** used (it's
403
+ * Fulcrum-only and rejected by electrs); confirmation status is derived
404
+ * from `transaction.get_merkle` plus parsed block headers.
405
+ *
406
+ * Output amounts are derived from parsed raw transaction bytes (exact
407
+ * bigints), never the floating-point `value` fields some servers return.
408
+ *
409
+ * Atomic 1P1C package broadcast (TRUC / BIP 431) is supported via
410
+ * Fulcrum's `blockchain.transaction.broadcast_package`. There is **no
411
+ * fallback** to sequential parent-then-child broadcasts — TRUC packages
412
+ * with a zero-fee parent would silently fail, so the call surfaces an
413
+ * error against servers that don't support the method.
414
+ *
415
+ * @example Default URL via {@link ELECTRUM_WS_URL}
294
416
  * ```typescript
295
417
  * import { ElectrumWS } from "ws-electrumx-client";
296
- * import { ElectrumOnchainProvider } from "./providers/electrum";
297
- * import { networks } from "./networks";
418
+ * import {
419
+ * ElectrumOnchainProvider,
420
+ * ELECTRUM_WS_URL,
421
+ * networks,
422
+ * } from "@arkade-os/sdk";
298
423
  *
299
- * const ws = new ElectrumWS("wss://electrum.blockstream.info:50004");
424
+ * const ws = new ElectrumWS(ELECTRUM_WS_URL.bitcoin);
300
425
  * const provider = new ElectrumOnchainProvider(ws, networks.bitcoin);
301
426
  *
302
427
  * const coins = await provider.getCoins("bc1q...");
428
+ * await provider.close();
429
+ * ```
430
+ *
431
+ * @example Custom server
432
+ * ```typescript
433
+ * const ws = new ElectrumWS("wss://my-fulcrum.example:50004");
434
+ * const provider = new ElectrumOnchainProvider(ws, networks.bitcoin);
303
435
  * ```
304
436
  */
305
437
  class ElectrumOnchainProvider {
@@ -379,7 +511,7 @@ class ElectrumOnchainProvider {
379
511
  return results;
380
512
  // Step 2: batch listunspent for all output scripthashes (1 round trip)
381
513
  // This tells us exactly which txid:vout pairs are still unspent.
382
- const unspentBatch = await this.ws.batchRequest(...validScriptHashes.map((sh) => ({
514
+ const unspentBatch = await this.chain.safeBatchRequest(validScriptHashes.map((sh) => ({
383
515
  method: ListUnspentMethod,
384
516
  params: [sh],
385
517
  })));
@@ -405,7 +537,7 @@ class ElectrumOnchainProvider {
405
537
  }
406
538
  if (spentIndices.length === 0)
407
539
  return results;
408
- const histories = await this.ws.batchRequest(...spentScriptHashes.map((sh) => ({
540
+ const histories = await this.chain.safeBatchRequest(spentScriptHashes.map((sh) => ({
409
541
  method: GetHistoryMethod,
410
542
  params: [sh],
411
543
  })));
@@ -464,34 +596,90 @@ class ElectrumOnchainProvider {
464
596
  const history = await this.chain.fetchHistory(script);
465
597
  if (history.length === 0)
466
598
  return [];
599
+ return this.historyToExplorerTxs(history);
600
+ }
601
+ /**
602
+ * Resolve a list of `{tx_hash, height}` entries (as returned by the
603
+ * scripthash history endpoint) into ExplorerTransaction shape **without
604
+ * using the verbose-tx endpoint**, which only Fulcrum implements. We
605
+ * reconstruct everything the verbose response would have given us:
606
+ * - vouts ← parse the raw tx (exact sat amounts, no float precision risk)
607
+ * - block_time ← batch-fetch the block headers for the heights present
608
+ * - addresses ← decode each output's scriptPubKey via @scure/btc-signer
609
+ */
610
+ async historyToExplorerTxs(history) {
467
611
  const txids = history.map((h) => h.tx_hash);
468
- const verboseTxs = await this.chain.fetchVerboseTransactions(txids);
469
- return verboseTxs.map((vtx) => this.verboseToExplorer(vtx));
612
+ const rawTxs = await this.chain.fetchTransactions(txids);
613
+ const rawHexByTxid = new Map(rawTxs.map((t) => [t.txID, t.hex]));
614
+ // De-duplicated batch lookup of block headers (now safe — see
615
+ // safeBatchRequest above). Heights whose headers fail to resolve
616
+ // surface via the wrapper's first-error throw; we tolerate that
617
+ // here by falling back to per-height calls under Promise.allSettled
618
+ // so one missing header doesn't poison the whole history mapping.
619
+ // The old verbose-tx code had the same tolerance via
620
+ // `vtx.blocktime || vtx.time || 0`.
621
+ const confirmedHeights = [
622
+ ...new Set(history.map((h) => h.height).filter((h) => h > 0)),
623
+ ];
624
+ const blockTimeByHeight = new Map();
625
+ if (confirmedHeights.length > 0) {
626
+ try {
627
+ const headers = await this.chain.fetchBlockHeaders(confirmedHeights);
628
+ for (const header of headers) {
629
+ blockTimeByHeight.set(header.height, parseBlockHeader(header.hex).timestamp);
630
+ }
631
+ }
632
+ catch {
633
+ const settled = await Promise.allSettled(confirmedHeights.map((h) => this.chain.fetchBlockHeader(h)));
634
+ settled.forEach((res) => {
635
+ if (res.status === "fulfilled") {
636
+ blockTimeByHeight.set(res.value.height, parseBlockHeader(res.value.hex).timestamp);
637
+ }
638
+ // Rejections leave the height absent → block_time = 0.
639
+ });
640
+ }
641
+ }
642
+ return history.map((entry) => this.buildExplorerTx(entry, rawHexByTxid.get(entry.tx_hash), blockTimeByHeight));
470
643
  }
471
644
  /**
472
- * Map an electrum verbose transaction to the ExplorerTransaction shape.
473
- *
474
- * Output values are derived from the raw transaction hex when available,
475
- * never from the floating-point `value` field returned by the daemon.
476
- * That field has 8 decimal places and `Math.round(value * 1e8)` is safe
477
- * in the common case but a footgun for protocol-level money handling —
478
- * the raw bytes are exact.
645
+ * Build an ExplorerTransaction from a history entry plus the raw tx hex
646
+ * (when known) and a height→block_time map. Parse errors propagate —
647
+ * silently returning an empty vout would hide real outputs (e.g. a
648
+ * deposit) and is far worse for protocol-level money handling than
649
+ * failing the whole batch.
479
650
  */
480
- verboseToExplorer(vtx) {
481
- const exactValuesByVout = parseExactSats(vtx);
651
+ buildExplorerTx(entry, rawHex, blockTimeByHeight) {
652
+ const vout = [];
653
+ if (rawHex) {
654
+ let tx;
655
+ try {
656
+ tx = btc_signer_1.Transaction.fromRaw(base_1.hex.decode(rawHex), {
657
+ allowUnknownOutputs: true,
658
+ allowUnknownInputs: true,
659
+ });
660
+ }
661
+ catch (err) {
662
+ throw new Error(`Failed to parse raw tx for ${entry.tx_hash}: ${err instanceof Error ? err.message : String(err)}`);
663
+ }
664
+ for (let i = 0; i < tx.outputsLength; i++) {
665
+ const output = tx.getOutput(i);
666
+ const scriptHex = output.script
667
+ ? base_1.hex.encode(output.script)
668
+ : "";
669
+ vout.push({
670
+ scriptpubkey_address: scriptHex
671
+ ? (this.chain.addressForScript(scriptHex) ?? "")
672
+ : "",
673
+ value: (output.amount ?? 0n).toString(),
674
+ });
675
+ }
676
+ }
482
677
  return {
483
- txid: vtx.txid,
484
- vout: vtx.vout.map((v) => ({
485
- scriptpubkey_address: v.scriptPubKey.address ||
486
- v.scriptPubKey.addresses?.[0] ||
487
- this.chain.addressForScript(v.scriptPubKey.hex) ||
488
- "",
489
- value: exactValuesByVout?.get(v.n) ??
490
- String(Math.round(v.value * 1e8)),
491
- })),
678
+ txid: entry.tx_hash,
679
+ vout,
492
680
  status: {
493
- confirmed: vtx.confirmations > 0,
494
- block_time: vtx.blocktime || vtx.time || 0,
681
+ confirmed: entry.height > 0,
682
+ block_time: blockTimeByHeight.get(entry.height) ?? 0,
495
683
  },
496
684
  };
497
685
  }
@@ -510,19 +698,31 @@ class ElectrumOnchainProvider {
510
698
  }
511
699
  }
512
700
  async getTxStatus(txid) {
513
- const vtx = await this.chain.fetchVerboseTransaction(txid);
514
- if (vtx.confirmations <= 0) {
701
+ // Use `transaction.get_merkle` rather than the verbose `transaction.get`
702
+ // because electrs (used by mempool.space, blockstream.info, and the
703
+ // nigiri regtest) doesn't implement verbose. get_merkle is part of the
704
+ // standard SPV protocol and supported by every Electrum server.
705
+ const merkle = await this.chain.fetchTxMerkle(txid);
706
+ if (!merkle)
515
707
  return { confirmed: false };
708
+ // Header lookup can transiently race with electrs's index right
709
+ // after a fresh block — listunspent/get_merkle expose the new
710
+ // height before block.header(N) is queryable. Tolerate that the
711
+ // same way historyToExplorerTxs does: confirmation status and
712
+ // height are still authoritative; only block_time degrades.
713
+ let blockTime = 0;
714
+ try {
715
+ const header = await this.chain.fetchBlockHeader(merkle.blockHeight);
716
+ blockTime = parseBlockHeader(header.hex).timestamp;
717
+ }
718
+ catch (err) {
719
+ if (!isMissingHeightError(err))
720
+ throw err;
516
721
  }
517
- // Get block height from the verbose tx's blockhash
518
- // We need the height, which is confirmations-based:
519
- // height = tipHeight - confirmations + 1
520
- const tip = await this.chain.subscribeHeaders();
521
- const blockHeight = tip.height - vtx.confirmations + 1;
522
722
  return {
523
723
  confirmed: true,
524
- blockTime: vtx.blocktime || vtx.time || 0,
525
- blockHeight,
724
+ blockHeight: merkle.blockHeight,
725
+ blockTime,
526
726
  };
527
727
  }
528
728
  async getChainTip() {
@@ -560,16 +760,21 @@ class ElectrumOnchainProvider {
560
760
  return;
561
761
  const history = await this.chain.fetchHistory(script);
562
762
  const known = knownTxids.get(scripthash) ?? new Set();
563
- const newTxids = history
564
- .map((h) => h.tx_hash)
565
- .filter((txid) => !known.has(txid));
566
- if (newTxids.length === 0)
763
+ const newEntries = history.filter((entry) => !known.has(entry.tx_hash));
764
+ if (newEntries.length === 0)
567
765
  return;
568
- for (const txid of newTxids)
569
- known.add(txid);
766
+ // Map the new history entries through the same non-verbose
767
+ // pipeline getTransactions uses, so subscribe-driven and
768
+ // poll-driven callers see ExplorerTransactions of identical shape.
769
+ // The dedupe set is updated ONLY after delivery succeeds —
770
+ // otherwise a failed fetch or callback would permanently mark
771
+ // these txids as seen and the next notification wouldn't
772
+ // re-deliver them.
773
+ const explorerTxs = await this.historyToExplorerTxs(newEntries);
774
+ eventCallback(explorerTxs);
775
+ for (const entry of newEntries)
776
+ known.add(entry.tx_hash);
570
777
  knownTxids.set(scripthash, known);
571
- const verboseTxs = await this.chain.fetchVerboseTransactions(newTxids);
572
- eventCallback(verboseTxs.map((vtx) => this.verboseToExplorer(vtx)));
573
778
  };
574
779
  const handleStatusChange = (scripthash) => {
575
780
  const previous = inFlight.get(scripthash) ?? Promise.resolve();
@@ -617,6 +822,33 @@ function isHeaderSubscribeResult(v) {
617
822
  const obj = v;
618
823
  return typeof obj.height === "number" && typeof obj.hex === "string";
619
824
  }
825
+ /**
826
+ * Recognise the "block header not yet indexable" failure shape returned by
827
+ * electrum servers (electrs in particular) when `block.header(N)` runs
828
+ * against a height that's already in `listunspent`/`get_merkle` but hasn't
829
+ * been indexed yet. Surfaced as `missingheight`. Tolerated by callers so
830
+ * the index-lag race doesn't poison confirmed-status reads; genuine
831
+ * failures (auth/network) propagate.
832
+ */
833
+ function isMissingHeightError(err) {
834
+ const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
835
+ return msg.toLowerCase().includes("missingheight");
836
+ }
837
+ /**
838
+ * Recognise the "transaction not in a block yet" failure shape returned by
839
+ * electrum servers when `blockchain.transaction.get_merkle` is asked about a
840
+ * mempool tx. electrs surfaces this as the strings below; Fulcrum mirrors
841
+ * the wording. We match conservatively so genuine errors (auth, network,
842
+ * malformed response) still propagate.
843
+ */
844
+ function isTxNotInBlockError(err) {
845
+ const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
846
+ const normalized = msg.toLowerCase();
847
+ return (normalized.includes("not yet in a block") ||
848
+ normalized.includes("not in a block") ||
849
+ normalized.includes("not in block") ||
850
+ normalized.includes("no confirmed transaction"));
851
+ }
620
852
  /**
621
853
  * Compute the txid of a serialized transaction. For segwit transactions
622
854
  * (every Ark transaction), the broadcast hex includes witness data, but
@@ -635,29 +867,3 @@ function childTxidFromHex(txHex) {
635
867
  });
636
868
  return tx.id;
637
869
  }
638
- /**
639
- * Decode `vtx.hex` (when the daemon includes it) and return a map of
640
- * vout-index → exact sat amount as a base-10 string. Returns `null` if
641
- * the hex is missing or unparseable; callers should fall back to the
642
- * float-derived value in that case.
643
- */
644
- function parseExactSats(vtx) {
645
- if (!vtx.hex)
646
- return null;
647
- try {
648
- const tx = btc_signer_1.Transaction.fromRaw(base_1.hex.decode(vtx.hex), {
649
- allowUnknownOutputs: true,
650
- });
651
- const result = new Map();
652
- for (let i = 0; i < tx.outputsLength; i++) {
653
- const output = tx.getOutput(i);
654
- if (output.amount === undefined)
655
- continue;
656
- result.set(i, output.amount.toString());
657
- }
658
- return result;
659
- }
660
- catch {
661
- return null;
662
- }
663
- }
@@ -34,7 +34,7 @@ function convertVtxo(vtxo) {
34
34
  script: vtxo.script,
35
35
  assets: vtxo.assets?.map((a) => ({
36
36
  assetId: a.assetId,
37
- amount: Number(a.amount),
37
+ amount: BigInt(a.amount),
38
38
  })),
39
39
  };
40
40
  }
@@ -358,7 +358,7 @@ class RestIndexerProvider {
358
358
  : undefined;
359
359
  return {
360
360
  assetId: data.assetId ?? assetId,
361
- supply: Number(data.supply ?? 0),
361
+ supply: BigInt(data.supply ?? 0),
362
362
  metadata,
363
363
  controlAssetId: data.controlAsset || undefined,
364
364
  };
@@ -449,7 +449,7 @@ function convertVtxo(vtxo) {
449
449
  script: vtxo.script,
450
450
  assets: vtxo.assets?.map((a) => ({
451
451
  assetId: a.assetId,
452
- amount: Number(a.amount),
452
+ amount: BigInt(a.amount),
453
453
  })),
454
454
  };
455
455
  }
@@ -3,12 +3,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.EsploraProvider = exports.ESPLORA_URL = void 0;
4
4
  /**
5
5
  * The default base URLs for esplora API providers.
6
+ *
7
+ * Mainnet, mutinynet, and signet point at Ark Labs–operated
8
+ * mempool deployments (mempool.space-compatible esplora API).
9
+ * Testnet falls back to the public mempool.space deployment
10
+ * because Ark doesn't host it. Regtest assumes a local nigiri
11
+ * stack on the standard port.
6
12
  */
7
13
  exports.ESPLORA_URL = {
8
- bitcoin: "https://mempool.space/api",
14
+ bitcoin: "https://mempool.arkade.sh/api",
9
15
  testnet: "https://mempool.space/testnet/api",
10
- signet: "https://mempool.space/signet/api",
11
- mutinynet: "https://mutinynet.com/api",
16
+ signet: "https://mempool.signet.arkade.sh/api",
17
+ mutinynet: "https://mempool.mutinynet.arkade.sh/api",
12
18
  regtest: "http://localhost:3000",
13
19
  };
14
20
  /**
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WalletRepositoryImpl = void 0;
4
4
  const base_1 = require("@scure/base");
5
5
  const btc_signer_1 = require("@scure/btc-signer");
6
+ const serialization_1 = require("../serialization");
6
7
  const getVtxosStorageKey = (address) => `vtxos:${address}`;
7
8
  const getUtxosStorageKey = (address) => `utxos:${address}`;
8
9
  const getTransactionsStorageKey = (address) => `tx:${address}`;
@@ -14,6 +15,7 @@ const serializeVtxo = (v) => ({
14
15
  forfeitTapLeafScript: serializeTapLeaf(v.forfeitTapLeafScript),
15
16
  intentTapLeafScript: serializeTapLeaf(v.intentTapLeafScript),
16
17
  extraWitness: v.extraWitness?.map(base_1.hex.encode),
18
+ assets: (0, serialization_1.serializeAssets)(v.assets),
17
19
  });
18
20
  const serializeUtxo = (u) => ({
19
21
  ...u,
@@ -29,6 +31,7 @@ const deserializeVtxo = (o) => ({
29
31
  forfeitTapLeafScript: deserializeTapLeaf(o.forfeitTapLeafScript),
30
32
  intentTapLeafScript: deserializeTapLeaf(o.intentTapLeafScript),
31
33
  extraWitness: o.extraWitness?.map(base_1.hex.decode),
34
+ assets: (0, serialization_1.deserializeAssets)(o.assets),
32
35
  });
33
36
  const deserializeUtxo = (o) => ({
34
37
  ...o,
@@ -124,7 +127,8 @@ class WalletRepositoryImpl {
124
127
  if (!stored)
125
128
  return [];
126
129
  try {
127
- return JSON.parse(stored);
130
+ const parsed = JSON.parse(stored);
131
+ return parsed.map(serialization_1.deserializeTransaction);
128
132
  }
129
133
  catch (error) {
130
134
  console.error(`Failed to parse transactions for address ${address}:`, error);
@@ -144,7 +148,7 @@ class WalletRepositoryImpl {
144
148
  storedTransactions.push(tx);
145
149
  }
146
150
  }
147
- await this.storage.setItem(getTransactionsStorageKey(address), JSON.stringify(storedTransactions));
151
+ await this.storage.setItem(getTransactionsStorageKey(address), JSON.stringify(storedTransactions.map(serialization_1.serializeTransaction)));
148
152
  }
149
153
  async clearTransactions(address) {
150
154
  return this.deleteTransactions(address);
@@ -154,7 +154,7 @@ class RealmWalletRepository {
154
154
  settled: tx.settled,
155
155
  createdAt: tx.createdAt,
156
156
  assetsJson: tx.assets
157
- ? JSON.stringify(tx.assets)
157
+ ? JSON.stringify((0, serialization_1.serializeAssets)(tx.assets))
158
158
  : null,
159
159
  }, "modified");
160
160
  }
@@ -271,7 +271,7 @@ function txObjectToDomain(obj) {
271
271
  createdAt: obj.createdAt,
272
272
  };
273
273
  if (obj.assetsJson) {
274
- tx.assets = JSON.parse(obj.assetsJson);
274
+ tx.assets = (0, serialization_1.deserializeAssets)(JSON.parse(obj.assetsJson));
275
275
  }
276
276
  return tx;
277
277
  }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.deserializeUtxo = exports.deserializeVtxo = exports.deserializeTapLeaf = exports.serializeUtxo = exports.serializeVtxo = exports.serializeTapLeaf = void 0;
3
+ exports.deserializeTransaction = exports.deserializeUtxo = exports.deserializeVtxo = exports.deserializeTapLeaf = exports.serializeTransaction = exports.serializeUtxo = exports.serializeVtxo = exports.deserializeAssets = exports.serializeAssets = exports.deserializeAsset = exports.serializeAsset = exports.serializeTapLeaf = void 0;
4
4
  const base_1 = require("@scure/base");
5
5
  const btc_signer_1 = require("@scure/btc-signer");
6
6
  const serializeTapLeaf = ([cb, s,]) => ({
@@ -8,12 +8,34 @@ const serializeTapLeaf = ([cb, s,]) => ({
8
8
  s: base_1.hex.encode(s),
9
9
  });
10
10
  exports.serializeTapLeaf = serializeTapLeaf;
11
+ const serializeAsset = (a) => ({
12
+ assetId: a.assetId,
13
+ amount: a.amount.toString(),
14
+ });
15
+ exports.serializeAsset = serializeAsset;
16
+ // Accept legacy persisted shapes where `amount` is a `number` — pre-bigint
17
+ // data already on disk must keep round-tripping.
18
+ const deserializeAsset = (a) => {
19
+ if (typeof a.amount === "number" && !Number.isSafeInteger(a.amount)) {
20
+ throw new Error(`Unsafe legacy asset amount for ${a.assetId}; re-sync from the original source`);
21
+ }
22
+ return {
23
+ assetId: a.assetId,
24
+ amount: typeof a.amount === "bigint" ? a.amount : BigInt(a.amount),
25
+ };
26
+ };
27
+ exports.deserializeAsset = deserializeAsset;
28
+ const serializeAssets = (assets) => assets?.map(exports.serializeAsset);
29
+ exports.serializeAssets = serializeAssets;
30
+ const deserializeAssets = (assets) => assets?.map(exports.deserializeAsset);
31
+ exports.deserializeAssets = deserializeAssets;
11
32
  const serializeVtxo = (v) => ({
12
33
  ...v,
13
34
  tapTree: base_1.hex.encode(v.tapTree),
14
35
  forfeitTapLeafScript: (0, exports.serializeTapLeaf)(v.forfeitTapLeafScript),
15
36
  intentTapLeafScript: (0, exports.serializeTapLeaf)(v.intentTapLeafScript),
16
37
  extraWitness: v.extraWitness?.map(base_1.hex.encode),
38
+ assets: (0, exports.serializeAssets)(v.assets),
17
39
  });
18
40
  exports.serializeVtxo = serializeVtxo;
19
41
  const serializeUtxo = (u) => ({
@@ -24,6 +46,11 @@ const serializeUtxo = (u) => ({
24
46
  extraWitness: u.extraWitness?.map(base_1.hex.encode),
25
47
  });
26
48
  exports.serializeUtxo = serializeUtxo;
49
+ const serializeTransaction = (t) => ({
50
+ ...t,
51
+ assets: (0, exports.serializeAssets)(t.assets),
52
+ });
53
+ exports.serializeTransaction = serializeTransaction;
27
54
  const deserializeTapLeaf = (t) => {
28
55
  const cb = btc_signer_1.TaprootControlBlock.decode(base_1.hex.decode(t.cb));
29
56
  const s = base_1.hex.decode(t.s);
@@ -37,6 +64,7 @@ const deserializeVtxo = (o) => ({
37
64
  forfeitTapLeafScript: (0, exports.deserializeTapLeaf)(o.forfeitTapLeafScript),
38
65
  intentTapLeafScript: (0, exports.deserializeTapLeaf)(o.intentTapLeafScript),
39
66
  extraWitness: o.extraWitness?.map(base_1.hex.decode),
67
+ assets: (0, exports.deserializeAssets)(o.assets),
40
68
  });
41
69
  exports.deserializeVtxo = deserializeVtxo;
42
70
  const deserializeUtxo = (o) => ({
@@ -47,3 +75,8 @@ const deserializeUtxo = (o) => ({
47
75
  extraWitness: o.extraWitness?.map(base_1.hex.decode),
48
76
  });
49
77
  exports.deserializeUtxo = deserializeUtxo;
78
+ const deserializeTransaction = (o) => ({
79
+ ...o,
80
+ assets: (0, exports.deserializeAssets)(o.assets),
81
+ });
82
+ exports.deserializeTransaction = deserializeTransaction;