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