@arkade-os/sdk 0.3.0-alpha.7 → 0.3.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +51 -0
  2. package/dist/cjs/adapters/expo.js +8 -0
  3. package/dist/cjs/index.js +2 -1
  4. package/dist/cjs/providers/expoArk.js +237 -0
  5. package/dist/cjs/providers/expoIndexer.js +194 -0
  6. package/dist/cjs/providers/indexer.js +3 -1
  7. package/dist/cjs/utils/arkTransaction.js +13 -0
  8. package/dist/cjs/wallet/index.js +1 -1
  9. package/dist/cjs/wallet/serviceWorker/utils.js +0 -9
  10. package/dist/cjs/wallet/serviceWorker/worker.js +14 -17
  11. package/dist/cjs/wallet/utils.js +11 -0
  12. package/dist/cjs/wallet/wallet.js +69 -51
  13. package/dist/esm/adapters/expo.js +3 -0
  14. package/dist/esm/index.js +2 -2
  15. package/dist/esm/providers/expoArk.js +200 -0
  16. package/dist/esm/providers/expoIndexer.js +157 -0
  17. package/dist/esm/providers/indexer.js +3 -1
  18. package/dist/esm/utils/arkTransaction.js +13 -1
  19. package/dist/esm/wallet/index.js +1 -1
  20. package/dist/esm/wallet/serviceWorker/utils.js +0 -8
  21. package/dist/esm/wallet/serviceWorker/worker.js +15 -18
  22. package/dist/esm/wallet/utils.js +8 -0
  23. package/dist/esm/wallet/wallet.js +70 -52
  24. package/dist/types/adapters/expo.d.ts +4 -0
  25. package/dist/types/index.d.ts +5 -5
  26. package/dist/types/providers/ark.d.ts +136 -2
  27. package/dist/types/providers/expoArk.d.ts +22 -0
  28. package/dist/types/providers/expoIndexer.d.ts +26 -0
  29. package/dist/types/providers/indexer.d.ts +8 -0
  30. package/dist/types/utils/arkTransaction.d.ts +3 -1
  31. package/dist/types/wallet/index.d.ts +44 -6
  32. package/dist/types/wallet/serviceWorker/utils.d.ts +0 -2
  33. package/dist/types/wallet/utils.d.ts +2 -0
  34. package/dist/types/wallet/wallet.d.ts +9 -1
  35. package/package.json +11 -2
@@ -13,7 +13,7 @@ const indexer_1 = require("../../providers/indexer");
13
13
  const base_1 = require("@scure/base");
14
14
  const indexedDB_1 = require("../../storage/indexedDB");
15
15
  const walletRepository_1 = require("../../repositories/walletRepository");
16
- const utils_1 = require("./utils");
16
+ const utils_1 = require("../utils");
17
17
  /**
18
18
  * Worker is a class letting to interact with ServiceWorkerWallet from the client
19
19
  * it aims to be run in a service worker context
@@ -77,6 +77,8 @@ class Worker {
77
77
  this.incomingFundsSubscription();
78
78
  // Clear storage - this replaces vtxoRepository.close()
79
79
  await this.storage.clear();
80
+ // Reset in-memory caches by recreating the repository
81
+ this.walletRepository = new walletRepository_1.WalletRepositoryImpl(this.storage);
80
82
  this.wallet = undefined;
81
83
  this.arkProvider = undefined;
82
84
  this.indexerProvider = undefined;
@@ -105,9 +107,6 @@ class Worker {
105
107
  const txs = await this.wallet.getTransactionHistory();
106
108
  if (txs)
107
109
  await this.walletRepository.saveTransactions(address, txs);
108
- // stop previous subscriptions if any
109
- if (this.incomingFundsSubscription)
110
- this.incomingFundsSubscription();
111
110
  // subscribe for incoming funds and notify all clients when new funds arrive
112
111
  this.incomingFundsSubscription = await this.wallet.notifyIncomingFunds(async (funds) => {
113
112
  if (funds.type === "vtxo") {
@@ -127,7 +126,7 @@ class Worker {
127
126
  // notify all clients about the vtxo update
128
127
  this.sendMessageToAllClients("VTXO_UPDATE", JSON.stringify({ newVtxos, spentVtxos }));
129
128
  }
130
- if (funds.type === "utxo" && funds.coins.length > 0) {
129
+ if (funds.type === "utxo") {
131
130
  // notify all clients about the utxo update
132
131
  this.sendMessageToAllClients("UTXO_UPDATE", JSON.stringify(funds.coins));
133
132
  }
@@ -358,17 +357,16 @@ class Worker {
358
357
  if (!message.filter?.withRecoverable) {
359
358
  if (!this.wallet)
360
359
  throw new Error("Wallet not initialized");
361
- // exclude subdust is we don't want recoverable
362
- const dustAmount = this.wallet?.dustAmount;
363
- vtxos =
364
- dustAmount == null
365
- ? vtxos
366
- : vtxos.filter((v) => !(0, __1.isSubdust)(v, dustAmount));
367
- }
368
- if (message.filter?.withRecoverable) {
369
- // get also swept and spendable vtxos
370
- const sweptVtxos = await this.getSweptVtxos();
371
- vtxos.push(...sweptVtxos.filter(__1.isSpendable));
360
+ // exclude subdust and recoverable if we don't want recoverable
361
+ const notSubdust = (v) => {
362
+ const dustAmount = this.wallet?.dustAmount;
363
+ return dustAmount == null
364
+ ? true
365
+ : !(0, __1.isSubdust)(v, dustAmount);
366
+ };
367
+ vtxos = vtxos
368
+ .filter(notSubdust)
369
+ .filter((v) => !(0, __1.isRecoverable)(v));
372
370
  }
373
371
  event.source?.postMessage(response_1.Response.vtxos(message.id, vtxos));
374
372
  }
@@ -529,7 +527,6 @@ class Worker {
529
527
  }
530
528
  async handleReloadWallet(event) {
531
529
  const message = event.data;
532
- console.log("RELOAD_WALLET message received", message);
533
530
  if (!request_1.Request.isReloadWallet(message)) {
534
531
  console.error("Invalid RELOAD_WALLET message format", message);
535
532
  event.source?.postMessage(response_1.Response.error(message.id, "Invalid RELOAD_WALLET message format"));
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extendVirtualCoin = extendVirtualCoin;
4
+ function extendVirtualCoin(wallet, vtxo) {
5
+ return {
6
+ ...vtxo,
7
+ forfeitTapLeafScript: wallet.offchainTapscript.forfeit(),
8
+ intentTapLeafScript: wallet.offchainTapscript.exit(),
9
+ tapTree: wallet.offchainTapscript.encode(),
10
+ };
11
+ }
@@ -60,7 +60,7 @@ const txTree_1 = require("../tree/txTree");
60
60
  const inMemory_1 = require("../storage/inMemory");
61
61
  const walletRepository_1 = require("../repositories/walletRepository");
62
62
  const contractRepository_1 = require("../repositories/contractRepository");
63
- const utils_1 = require("./serviceWorker/utils");
63
+ const utils_1 = require("./utils");
64
64
  /**
65
65
  * Main wallet implementation for Bitcoin transactions with Ark protocol support.
66
66
  * The wallet does not store any data locally and relies on Ark and onchain
@@ -68,13 +68,21 @@ const utils_1 = require("./serviceWorker/utils");
68
68
  *
69
69
  * @example
70
70
  * ```typescript
71
- * // Create a wallet
71
+ * // Create a wallet with URL configuration
72
72
  * const wallet = await Wallet.create({
73
73
  * identity: SingleKey.fromHex('your_private_key'),
74
74
  * arkServerUrl: 'https://ark.example.com',
75
75
  * esploraUrl: 'https://mempool.space/api'
76
76
  * });
77
77
  *
78
+ * // Or with custom provider instances (e.g., for Expo/React Native)
79
+ * const wallet = await Wallet.create({
80
+ * identity: SingleKey.fromHex('your_private_key'),
81
+ * arkProvider: new ExpoArkProvider('https://ark.example.com'),
82
+ * indexerProvider: new ExpoIndexerProvider('https://ark.example.com'),
83
+ * esploraUrl: 'https://mempool.space/api'
84
+ * });
85
+ *
78
86
  * // Get addresses
79
87
  * const arkAddress = await wallet.getAddress();
80
88
  * const boardingAddress = await wallet.getBoardingAddress();
@@ -108,11 +116,29 @@ class Wallet {
108
116
  if (!pubkey) {
109
117
  throw new Error("Invalid configured public key");
110
118
  }
111
- const arkProvider = new ark_1.RestArkProvider(config.arkServerUrl);
112
- const indexerProvider = new indexer_1.RestIndexerProvider(config.arkServerUrl);
119
+ // Use provided arkProvider instance or create a new one from arkServerUrl
120
+ const arkProvider = config.arkProvider ||
121
+ (() => {
122
+ if (!config.arkServerUrl) {
123
+ throw new Error("Either arkProvider or arkServerUrl must be provided");
124
+ }
125
+ return new ark_1.RestArkProvider(config.arkServerUrl);
126
+ })();
127
+ // Extract arkServerUrl from provider if not explicitly provided
128
+ const arkServerUrl = config.arkServerUrl || arkProvider.serverUrl;
129
+ if (!arkServerUrl) {
130
+ throw new Error("Could not determine arkServerUrl from provider");
131
+ }
132
+ // Use provided indexerProvider instance or create a new one
133
+ // indexerUrl defaults to arkServerUrl if not provided
134
+ const indexerUrl = config.indexerUrl || arkServerUrl;
135
+ const indexerProvider = config.indexerProvider || new indexer_1.RestIndexerProvider(indexerUrl);
113
136
  const info = await arkProvider.getInfo();
114
137
  const network = (0, networks_1.getNetwork)(info.network);
115
- const onchainProvider = new onchain_1.EsploraProvider(config.esploraUrl || onchain_1.ESPLORA_URL[info.network]);
138
+ // Extract esploraUrl from provider if not explicitly provided
139
+ const esploraUrl = config.esploraUrl || onchain_1.ESPLORA_URL[info.network];
140
+ // Use provided onchainProvider instance or create a new one
141
+ const onchainProvider = config.onchainProvider || new onchain_1.EsploraProvider(esploraUrl);
116
142
  const exitTimelock = {
117
143
  value: info.unilateralExitDelay,
118
144
  type: info.unilateralExitDelay < 512n ? "blocks" : "seconds",
@@ -136,8 +162,14 @@ class Wallet {
136
162
  // Save tapscripts
137
163
  const offchainTapscript = bareVtxoTapscript;
138
164
  // the serverUnrollScript is the one used to create output scripts of the checkpoint transactions
139
- const rawCheckpointExitClosure = base_1.hex.decode(info.checkpointExitClosure);
140
- const serverUnrollScript = tapscript_1.CSVMultisigTapscript.decode(rawCheckpointExitClosure);
165
+ let serverUnrollScript;
166
+ try {
167
+ const raw = base_1.hex.decode(info.checkpointExitClosure);
168
+ serverUnrollScript = tapscript_1.CSVMultisigTapscript.decode(raw);
169
+ }
170
+ catch (e) {
171
+ throw new Error("Invalid checkpointExitClosure from server");
172
+ }
141
173
  // parse the server forfeit address
142
174
  // server is expecting funds to be sent to this address
143
175
  const forfeitAddress = (0, payment_js_1.Address)(network).decode(info.forfeitAddress);
@@ -208,40 +240,24 @@ class Wallet {
208
240
  // if (cachedVtxos.length) return cachedVtxos;
209
241
  // For now, always fetch fresh data from provider and update cache
210
242
  // In future, we can add cache invalidation logic based on timestamps
211
- const spendableVtxos = await this.getVirtualCoins(filter);
212
- const encodedOffchainTapscript = this.offchainTapscript.encode();
213
- const forfeit = this.offchainTapscript.forfeit();
214
- const exit = this.offchainTapscript.exit();
215
- const extendedVtxos = spendableVtxos.map((vtxo) => ({
216
- ...vtxo,
217
- forfeitTapLeafScript: forfeit,
218
- intentTapLeafScript: exit,
219
- tapTree: encodedOffchainTapscript,
220
- }));
243
+ const vtxos = await this.getVirtualCoins(filter);
244
+ const extendedVtxos = vtxos.map((vtxo) => (0, utils_1.extendVirtualCoin)(this, vtxo));
221
245
  // Update cache with fresh data
222
246
  await this.walletRepository.saveVtxos(address, extendedVtxos);
223
247
  return extendedVtxos;
224
248
  }
225
249
  async getVirtualCoins(filter = { withRecoverable: true, withUnrolled: false }) {
226
250
  const scripts = [base_1.hex.encode(this.offchainTapscript.pkScript)];
227
- const response = await this.indexerProvider.getVtxos({
228
- scripts,
229
- spendableOnly: true,
230
- });
231
- const vtxos = response.vtxos;
232
- if (filter.withRecoverable) {
233
- const response = await this.indexerProvider.getVtxos({
234
- scripts,
235
- recoverableOnly: true,
236
- });
237
- vtxos.push(...response.vtxos);
251
+ const response = await this.indexerProvider.getVtxos({ scripts });
252
+ const allVtxos = response.vtxos;
253
+ let vtxos = allVtxos.filter(_1.isSpendable);
254
+ // all recoverable vtxos are spendable by definition
255
+ if (!filter.withRecoverable) {
256
+ vtxos = vtxos.filter((vtxo) => !(0, _1.isRecoverable)(vtxo));
238
257
  }
239
258
  if (filter.withUnrolled) {
240
- const response = await this.indexerProvider.getVtxos({
241
- scripts,
242
- spentOnly: true,
243
- });
244
- vtxos.push(...response.vtxos.filter((vtxo) => vtxo.isUnrolled));
259
+ const spentVtxos = allVtxos.filter((vtxo) => !(0, _1.isSpendable)(vtxo));
260
+ vtxos.push(...spentVtxos.filter((vtxo) => vtxo.isUnrolled));
245
261
  }
246
262
  return vtxos;
247
263
  }
@@ -279,10 +295,10 @@ class Wallet {
279
295
  return txs;
280
296
  }
281
297
  async getBoardingTxs() {
282
- const boardingAddress = await this.getBoardingAddress();
283
- const txs = await this.onchainProvider.getTransactions(boardingAddress);
284
298
  const utxos = [];
285
299
  const commitmentsToIgnore = new Set();
300
+ const boardingAddress = await this.getBoardingAddress();
301
+ const txs = await this.onchainProvider.getTransactions(boardingAddress);
286
302
  for (const tx of txs) {
287
303
  for (let i = 0; i < tx.vout.length; i++) {
288
304
  const vout = tx.vout[i];
@@ -423,13 +439,15 @@ class Wallet {
423
439
  }
424
440
  }
425
441
  }
426
- // if no params are provided, use all boarding and offchain utxos as inputs
442
+ // if no params are provided, use all non expired boarding utxos and offchain vtxos as inputs
427
443
  // and send all to the offchain address
428
444
  if (!params) {
429
445
  let amount = 0;
430
- const boardingUtxos = await this.getBoardingUtxos();
446
+ const exitScript = tapscript_1.CSVMultisigTapscript.decode(base_1.hex.decode(this.boardingTapscript.exitScript));
447
+ const boardingTimelock = exitScript.params.timelock;
448
+ const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => !(0, arkTransaction_1.hasBoardingTxExpired)(utxo, boardingTimelock));
431
449
  amount += boardingUtxos.reduce((sum, input) => sum + input.value, 0);
432
- const vtxos = await this.getVtxos();
450
+ const vtxos = await this.getVtxos({ withRecoverable: true });
433
451
  amount += vtxos.reduce((sum, input) => sum + input.value, 0);
434
452
  const inputs = [...boardingUtxos, ...vtxos];
435
453
  if (inputs.length === 0) {
@@ -639,22 +657,22 @@ class Wallet {
639
657
  let onchainStopFunc;
640
658
  let indexerStopFunc;
641
659
  if (this.onchainProvider && boardingAddress) {
660
+ const findVoutOnTx = (tx) => {
661
+ return tx.vout.findIndex((v) => v.scriptpubkey_address === boardingAddress);
662
+ };
642
663
  onchainStopFunc = await this.onchainProvider.watchAddresses([boardingAddress], (txs) => {
664
+ // find all utxos belonging to our boarding address
643
665
  const coins = txs
666
+ // filter txs where address is in output
667
+ .filter((tx) => findVoutOnTx(tx) !== -1)
668
+ // return utxo as Coin
644
669
  .map((tx) => {
645
- const vout = tx.vout.findIndex((v) => v.scriptpubkey_address === boardingAddress);
646
- if (vout === -1) {
647
- console.warn(`No vout found for address ${boardingAddress} in transaction ${tx.txid}`);
648
- return null;
649
- }
650
- return {
651
- txid: tx.txid,
652
- vout,
653
- value: Number(tx.vout[vout].value),
654
- status: tx.status,
655
- };
656
- })
657
- .filter((coin) => coin !== null);
670
+ const { txid, status } = tx;
671
+ const vout = findVoutOnTx(tx);
672
+ const value = Number(tx.vout[vout].value);
673
+ return { txid, vout, value, status };
674
+ });
675
+ // and notify via callback
658
676
  eventCallback({
659
677
  type: "utxo",
660
678
  coins,
@@ -0,0 +1,3 @@
1
+ // Expo adapter for React Native/Expo environments
2
+ export { ExpoArkProvider } from '../providers/expoArk.js';
3
+ export { ExpoIndexerProvider } from '../providers/expoIndexer.js';
package/dist/esm/index.js CHANGED
@@ -17,7 +17,7 @@ import { Response } from './wallet/serviceWorker/response.js';
17
17
  import { ESPLORA_URL, EsploraProvider, } from './providers/onchain.js';
18
18
  import { RestArkProvider, SettlementEventType, } from './providers/ark.js';
19
19
  import { CLTVMultisigTapscript, ConditionCSVMultisigTapscript, ConditionMultisigTapscript, CSVMultisigTapscript, decodeTapscript, MultisigTapscript, } from './script/tapscript.js';
20
- import { buildOffchainTx, } from './utils/arkTransaction.js';
20
+ import { hasBoardingTxExpired, buildOffchainTx, } from './utils/arkTransaction.js';
21
21
  import { VtxoTaprootTree, ConditionWitness, getArkPsbtFields, setArkPsbtField, ArkPsbtFieldKey, ArkPsbtFieldKeyType, CosignerPublicKey, VtxoTreeExpiry, } from './utils/unknownFields.js';
22
22
  import { BIP322 } from './bip322/index.js';
23
23
  import { ArkNote } from './arknote/index.js';
@@ -43,7 +43,7 @@ decodeTapscript, MultisigTapscript, CSVMultisigTapscript, ConditionCSVMultisigTa
43
43
  // Ark PSBT fields
44
44
  ArkPsbtFieldKey, ArkPsbtFieldKeyType, setArkPsbtField, getArkPsbtFields, CosignerPublicKey, VtxoTreeExpiry, VtxoTaprootTree, ConditionWitness,
45
45
  // Utils
46
- buildOffchainTx, waitForIncomingFunds,
46
+ buildOffchainTx, waitForIncomingFunds, hasBoardingTxExpired,
47
47
  // Arknote
48
48
  ArkNote,
49
49
  // Network
@@ -0,0 +1,200 @@
1
+ import { RestArkProvider, isFetchTimeoutError, } from './ark.js';
2
+ /**
3
+ * Expo-compatible Ark provider implementation using expo/fetch for SSE support.
4
+ * This provider works specifically in React Native/Expo environments where
5
+ * standard EventSource is not available but expo/fetch provides SSE capabilities.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { ExpoArkProvider } from '@arkade-os/sdk/providers/expo';
10
+ *
11
+ * const provider = new ExpoArkProvider('https://ark.example.com');
12
+ * const info = await provider.getInfo();
13
+ * ```
14
+ */
15
+ export class ExpoArkProvider extends RestArkProvider {
16
+ constructor(serverUrl) {
17
+ super(serverUrl);
18
+ }
19
+ async *getEventStream(signal, topics) {
20
+ // Dynamic import to avoid bundling expo/fetch in non-Expo environments
21
+ let expoFetch = fetch; // Default to standard fetch
22
+ try {
23
+ const expoFetchModule = await import("expo/fetch");
24
+ // expo/fetch returns a compatible fetch function but with different types
25
+ expoFetch = expoFetchModule.fetch;
26
+ console.debug("Using expo/fetch for SSE");
27
+ }
28
+ catch (error) {
29
+ // Fall back to standard fetch if expo/fetch is not available
30
+ console.warn("Using standard fetch instead of expo/fetch. " +
31
+ "Streaming may not be fully supported in some environments.", error);
32
+ }
33
+ const url = `${this.serverUrl}/v1/batch/events`;
34
+ const queryParams = topics.length > 0
35
+ ? `?${topics.map((topic) => `topics=${encodeURIComponent(topic)}`).join("&")}`
36
+ : "";
37
+ while (!signal?.aborted) {
38
+ // Create a new AbortController for this specific fetch attempt
39
+ // to prevent accumulating listeners on the parent signal
40
+ const fetchController = new AbortController();
41
+ const cleanup = () => fetchController.abort();
42
+ signal?.addEventListener("abort", cleanup, { once: true });
43
+ try {
44
+ const response = await expoFetch(url + queryParams, {
45
+ headers: {
46
+ Accept: "text/event-stream",
47
+ },
48
+ signal: fetchController.signal,
49
+ });
50
+ if (!response.ok) {
51
+ throw new Error(`Unexpected status ${response.status} when fetching event stream`);
52
+ }
53
+ if (!response.body) {
54
+ throw new Error("Response body is null");
55
+ }
56
+ const reader = response.body.getReader();
57
+ const decoder = new TextDecoder();
58
+ let buffer = "";
59
+ while (!signal?.aborted) {
60
+ const { done, value } = await reader.read();
61
+ if (done) {
62
+ break;
63
+ }
64
+ // Append new data to buffer and split by newlines
65
+ buffer += decoder.decode(value, { stream: true });
66
+ const lines = buffer.split("\n");
67
+ // Process all complete lines
68
+ for (let i = 0; i < lines.length - 1; i++) {
69
+ const line = lines[i].trim();
70
+ if (!line)
71
+ continue;
72
+ try {
73
+ // Parse SSE format: "data: {json}"
74
+ if (line.startsWith("data:")) {
75
+ const jsonStr = line.substring(5).trim();
76
+ if (!jsonStr)
77
+ continue;
78
+ const data = JSON.parse(jsonStr);
79
+ // Handle different response structures
80
+ // v8 mesh API might wrap in {result: ...} or send directly
81
+ const eventData = data.result || data;
82
+ // Skip heartbeat messages
83
+ if (eventData.heartbeat !== undefined) {
84
+ continue;
85
+ }
86
+ const event = this.parseSettlementEvent(eventData);
87
+ if (event) {
88
+ yield event;
89
+ }
90
+ }
91
+ }
92
+ catch (err) {
93
+ console.error("Failed to parse event:", line);
94
+ console.error("Parse error:", err);
95
+ throw err;
96
+ }
97
+ }
98
+ // Keep the last partial line in the buffer
99
+ buffer = lines[lines.length - 1];
100
+ }
101
+ }
102
+ catch (error) {
103
+ if (error instanceof Error && error.name === "AbortError") {
104
+ break;
105
+ }
106
+ // ignore timeout errors, they're expected when the server is not sending anything for 5 min
107
+ // these timeouts are set by expo/fetch function
108
+ if (isFetchTimeoutError(error)) {
109
+ console.debug("Timeout error ignored");
110
+ continue;
111
+ }
112
+ console.error("Event stream error:", error);
113
+ throw error;
114
+ }
115
+ finally {
116
+ // Clean up the abort listener
117
+ signal?.removeEventListener("abort", cleanup);
118
+ }
119
+ }
120
+ }
121
+ async *getTransactionsStream(signal) {
122
+ // Dynamic import to avoid bundling expo/fetch in non-Expo environments
123
+ let expoFetch = fetch; // Default to standard fetch
124
+ try {
125
+ const expoFetchModule = await import("expo/fetch");
126
+ // expo/fetch returns a compatible fetch function but with different types
127
+ expoFetch = expoFetchModule.fetch;
128
+ console.debug("Using expo/fetch for transaction stream");
129
+ }
130
+ catch (error) {
131
+ // Fall back to standard fetch if expo/fetch is not available
132
+ console.warn("Using standard fetch instead of expo/fetch. " +
133
+ "Streaming may not be fully supported in some environments.", error);
134
+ }
135
+ const url = `${this.serverUrl}/v1/txs`;
136
+ while (!signal?.aborted) {
137
+ // Create a new AbortController for this specific fetch attempt
138
+ // to prevent accumulating listeners on the parent signal
139
+ const fetchController = new AbortController();
140
+ const cleanup = () => fetchController.abort();
141
+ signal?.addEventListener("abort", cleanup, { once: true });
142
+ try {
143
+ const response = await expoFetch(url, {
144
+ headers: {
145
+ Accept: "text/event-stream",
146
+ },
147
+ signal: fetchController.signal,
148
+ });
149
+ if (!response.ok) {
150
+ throw new Error(`Unexpected status ${response.status} when fetching transaction stream`);
151
+ }
152
+ if (!response.body) {
153
+ throw new Error("Response body is null");
154
+ }
155
+ const reader = response.body.getReader();
156
+ const decoder = new TextDecoder();
157
+ let buffer = "";
158
+ while (!signal?.aborted) {
159
+ const { done, value } = await reader.read();
160
+ if (done) {
161
+ break;
162
+ }
163
+ // Append new data to buffer and split by newlines
164
+ buffer += decoder.decode(value, { stream: true });
165
+ const lines = buffer.split("\n");
166
+ // Process all complete lines
167
+ for (let i = 0; i < lines.length - 1; i++) {
168
+ const line = lines[i].trim();
169
+ if (!line)
170
+ continue;
171
+ const data = JSON.parse(line);
172
+ const txNotification = this.parseTransactionNotification(data.result);
173
+ if (txNotification) {
174
+ yield txNotification;
175
+ }
176
+ }
177
+ // Keep the last partial line in the buffer
178
+ buffer = lines[lines.length - 1];
179
+ }
180
+ }
181
+ catch (error) {
182
+ if (error instanceof Error && error.name === "AbortError") {
183
+ break;
184
+ }
185
+ // ignore timeout errors, they're expected when the server is not sending anything for 5 min
186
+ // these timeouts are set by expo/fetch function
187
+ if (isFetchTimeoutError(error)) {
188
+ console.debug("Timeout error ignored");
189
+ continue;
190
+ }
191
+ console.error("Address subscription error:", error);
192
+ throw error;
193
+ }
194
+ finally {
195
+ // Clean up the abort listener
196
+ signal?.removeEventListener("abort", cleanup);
197
+ }
198
+ }
199
+ }
200
+ }
@@ -0,0 +1,157 @@
1
+ import { RestIndexerProvider } from './indexer.js';
2
+ import { isFetchTimeoutError } from './ark.js';
3
+ // Helper function to convert Vtxo to VirtualCoin (same as in indexer.ts)
4
+ function convertVtxo(vtxo) {
5
+ return {
6
+ txid: vtxo.outpoint.txid,
7
+ vout: vtxo.outpoint.vout,
8
+ value: Number(vtxo.amount),
9
+ status: {
10
+ confirmed: !vtxo.isSwept && !vtxo.isPreconfirmed,
11
+ },
12
+ virtualStatus: {
13
+ state: vtxo.isSwept
14
+ ? "swept"
15
+ : vtxo.isPreconfirmed
16
+ ? "preconfirmed"
17
+ : "settled",
18
+ commitmentTxIds: vtxo.commitmentTxids,
19
+ batchExpiry: vtxo.expiresAt
20
+ ? Number(vtxo.expiresAt) * 1000
21
+ : undefined,
22
+ },
23
+ spentBy: vtxo.spentBy ?? "",
24
+ settledBy: vtxo.settledBy,
25
+ arkTxId: vtxo.arkTxid,
26
+ createdAt: new Date(Number(vtxo.createdAt) * 1000),
27
+ isUnrolled: vtxo.isUnrolled,
28
+ isSpent: vtxo.isSpent,
29
+ };
30
+ }
31
+ /**
32
+ * Expo-compatible Indexer provider implementation using expo/fetch for streaming support.
33
+ * This provider works specifically in React Native/Expo environments where
34
+ * standard fetch streaming may not work properly but expo/fetch provides streaming capabilities.
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * import { ExpoIndexerProvider } from '@arkade-os/sdk/adapters/expo';
39
+ *
40
+ * const provider = new ExpoIndexerProvider('https://indexer.example.com');
41
+ * const vtxos = await provider.getVtxos({ scripts: ['script1'] });
42
+ * ```
43
+ */
44
+ export class ExpoIndexerProvider extends RestIndexerProvider {
45
+ constructor(serverUrl) {
46
+ super(serverUrl);
47
+ }
48
+ async *getSubscription(subscriptionId, abortSignal) {
49
+ // Detect if we're running in React Native/Expo environment
50
+ const isReactNative = typeof navigator !== "undefined" &&
51
+ navigator.product === "ReactNative";
52
+ // Dynamic import to avoid bundling expo/fetch in non-Expo environments
53
+ let expoFetch = fetch; // Default to standard fetch
54
+ try {
55
+ const expoFetchModule = await import("expo/fetch");
56
+ // expo/fetch returns a compatible fetch function but with different types
57
+ expoFetch = expoFetchModule.fetch;
58
+ console.debug("Using expo/fetch for indexer subscription");
59
+ }
60
+ catch (error) {
61
+ // In React Native/Expo, expo/fetch is required for proper streaming support
62
+ if (isReactNative) {
63
+ throw new Error("expo/fetch is unavailable in React Native environment. " +
64
+ "Please ensure expo/fetch is installed and properly configured. " +
65
+ "Streaming support may not work with standard fetch in React Native.");
66
+ }
67
+ // In non-RN environments, fall back to standard fetch but warn about potential streaming issues
68
+ console.warn("Using standard fetch instead of expo/fetch. " +
69
+ "Streaming may not be fully supported in some environments.", error);
70
+ }
71
+ const url = `${this.serverUrl}/v1/indexer/script/subscription/${subscriptionId}`;
72
+ while (!abortSignal.aborted) {
73
+ try {
74
+ const res = await expoFetch(url, {
75
+ headers: {
76
+ Accept: "text/event-stream",
77
+ "Content-Type": "application/json",
78
+ },
79
+ signal: abortSignal,
80
+ });
81
+ if (!res.ok) {
82
+ throw new Error(`Unexpected status ${res.status} when subscribing to address updates`);
83
+ }
84
+ // Check if response is the expected content type
85
+ const contentType = res.headers.get("content-type");
86
+ if (contentType &&
87
+ !contentType.includes("text/event-stream") &&
88
+ !contentType.includes("application/json")) {
89
+ throw new Error(`Unexpected content-type: ${contentType}. Expected text/event-stream or application/json`);
90
+ }
91
+ if (!res.body) {
92
+ throw new Error("Response body is null");
93
+ }
94
+ const reader = res.body.getReader();
95
+ const decoder = new TextDecoder();
96
+ let buffer = "";
97
+ while (!abortSignal.aborted) {
98
+ const { done, value } = await reader.read();
99
+ if (done) {
100
+ break;
101
+ }
102
+ buffer += decoder.decode(value, { stream: true });
103
+ const lines = buffer.split("\n");
104
+ for (let i = 0; i < lines.length - 1; i++) {
105
+ const line = lines[i].trim();
106
+ if (!line)
107
+ continue;
108
+ try {
109
+ // Parse SSE format: "data: {json}"
110
+ if (line.startsWith("data:")) {
111
+ const jsonStr = line.substring(5).trim();
112
+ if (!jsonStr)
113
+ continue;
114
+ const data = JSON.parse(jsonStr);
115
+ // Handle new v8 proto format with heartbeat or event
116
+ if (data.heartbeat !== undefined) {
117
+ // Skip heartbeat messages
118
+ continue;
119
+ }
120
+ // Process event messages
121
+ if (data.event) {
122
+ yield {
123
+ txid: data.event.txid,
124
+ scripts: data.event.scripts || [],
125
+ newVtxos: (data.event.newVtxos || []).map(convertVtxo),
126
+ spentVtxos: (data.event.spentVtxos || []).map(convertVtxo),
127
+ sweptVtxos: (data.event.sweptVtxos || []).map(convertVtxo),
128
+ tx: data.event.tx,
129
+ checkpointTxs: data.event.checkpointTxs,
130
+ };
131
+ }
132
+ }
133
+ }
134
+ catch (parseError) {
135
+ console.error("Failed to parse subscription response:", parseError);
136
+ throw parseError;
137
+ }
138
+ }
139
+ buffer = lines[lines.length - 1];
140
+ }
141
+ }
142
+ catch (error) {
143
+ if (error instanceof Error && error.name === "AbortError") {
144
+ break;
145
+ }
146
+ // ignore timeout errors, they're expected when the server is not sending anything for 5 min
147
+ // these timeouts are set by expo/fetch function
148
+ if (isFetchTimeoutError(error)) {
149
+ console.debug("Timeout error ignored");
150
+ continue;
151
+ }
152
+ console.error("Subscription error:", error);
153
+ throw error;
154
+ }
155
+ }
156
+ }
157
+ }