@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.
- package/README.md +51 -0
- package/dist/cjs/adapters/expo.js +8 -0
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/providers/expoArk.js +237 -0
- package/dist/cjs/providers/expoIndexer.js +194 -0
- package/dist/cjs/providers/indexer.js +3 -1
- package/dist/cjs/utils/arkTransaction.js +13 -0
- package/dist/cjs/wallet/index.js +1 -1
- package/dist/cjs/wallet/serviceWorker/utils.js +0 -9
- package/dist/cjs/wallet/serviceWorker/worker.js +14 -17
- package/dist/cjs/wallet/utils.js +11 -0
- package/dist/cjs/wallet/wallet.js +69 -51
- package/dist/esm/adapters/expo.js +3 -0
- package/dist/esm/index.js +2 -2
- package/dist/esm/providers/expoArk.js +200 -0
- package/dist/esm/providers/expoIndexer.js +157 -0
- package/dist/esm/providers/indexer.js +3 -1
- package/dist/esm/utils/arkTransaction.js +13 -1
- package/dist/esm/wallet/index.js +1 -1
- package/dist/esm/wallet/serviceWorker/utils.js +0 -8
- package/dist/esm/wallet/serviceWorker/worker.js +15 -18
- package/dist/esm/wallet/utils.js +8 -0
- package/dist/esm/wallet/wallet.js +70 -52
- package/dist/types/adapters/expo.d.ts +4 -0
- package/dist/types/index.d.ts +5 -5
- package/dist/types/providers/ark.d.ts +136 -2
- package/dist/types/providers/expoArk.d.ts +22 -0
- package/dist/types/providers/expoIndexer.d.ts +26 -0
- package/dist/types/providers/indexer.d.ts +8 -0
- package/dist/types/utils/arkTransaction.d.ts +3 -1
- package/dist/types/wallet/index.d.ts +44 -6
- package/dist/types/wallet/serviceWorker/utils.d.ts +0 -2
- package/dist/types/wallet/utils.d.ts +2 -0
- package/dist/types/wallet/wallet.d.ts +9 -1
- 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("
|
|
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"
|
|
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
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
dustAmount == null
|
|
365
|
-
?
|
|
366
|
-
:
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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("./
|
|
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
|
-
|
|
112
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
|
212
|
-
const
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
241
|
-
|
|
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
|
|
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
|
|
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
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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,
|
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
|
+
}
|