@arkade-os/sdk 0.4.20 → 0.4.22

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.
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ContractWatcher = void 0;
4
- const utils_1 = require("../providers/utils");
4
+ const utils_1 = require("../wallet/utils");
5
+ const utils_2 = require("../providers/utils");
5
6
  /**
6
7
  * Watches multiple contracts for virtual output state changes with resilient connection handling.
7
8
  *
@@ -254,13 +255,18 @@ class ContractWatcher {
254
255
  }
255
256
  /**
256
257
  * Connect to the subscription.
258
+ *
259
+ * @param skipUpdate - Skip the leading `updateSubscription` call when
260
+ * the caller has already established `subscriptionId`.
257
261
  */
258
- async connect() {
262
+ async connect(skipUpdate = false) {
259
263
  if (!this.isWatching)
260
264
  return;
261
265
  this.connectionState = "connecting";
262
266
  try {
263
- await this.updateSubscription();
267
+ if (!skipUpdate) {
268
+ await this.updateSubscription();
269
+ }
264
270
  // Poll immediately after connection to sync state
265
271
  await this.pollAllContracts();
266
272
  this.connectionState = "connected";
@@ -271,7 +277,7 @@ class ContractWatcher {
271
277
  // indefinitely and block the caller.
272
278
  // Error management must be implemented to ensure the connection
273
279
  // is restored and events are fired.
274
- if ((0, utils_1.isEventSourceError)(e)) {
280
+ if ((0, utils_2.isEventSourceError)(e)) {
275
281
  console.debug("ContractWatcher subscription disconnected; reconnecting");
276
282
  }
277
283
  else {
@@ -391,11 +397,30 @@ class ContractWatcher {
391
397
  }
392
398
  }
393
399
  async tryUpdateSubscription() {
400
+ const hadSubscription = this.subscriptionId !== undefined;
394
401
  try {
395
402
  await this.updateSubscription();
396
403
  }
397
404
  catch (error) {
398
405
  // nothing, the connection will be retried later
406
+ return;
407
+ }
408
+ // Cold start: `startWatching` may have run with zero scripts,
409
+ // leaving `listenLoop` parked behind the reconnect timer. Kick
410
+ // `connect` now so streaming resumes without waiting on the
411
+ // backoff. `skipUpdate` avoids re-issuing `subscribeForScripts`.
412
+ const justGotSubscription = !hadSubscription && this.subscriptionId !== undefined;
413
+ const listenerParked = this.connectionState === "disconnected" ||
414
+ this.connectionState === "reconnecting";
415
+ if (this.isWatching && justGotSubscription && listenerParked) {
416
+ if (this.reconnectTimeoutId) {
417
+ clearTimeout(this.reconnectTimeoutId);
418
+ this.reconnectTimeoutId = undefined;
419
+ }
420
+ this.reconnectAttempts = 0;
421
+ this.connect(true).catch((error) => {
422
+ console.warn("ContractWatcher cold-start connect failed:", error);
423
+ });
399
424
  }
400
425
  }
401
426
  /**
@@ -529,18 +554,22 @@ class ContractWatcher {
529
554
  const state = this.contracts.get(contractScript);
530
555
  if (!state)
531
556
  return;
557
+ const extended = [];
558
+ for (const v of vtxos) {
559
+ try {
560
+ const extendedVtxo = (0, utils_1.extendVirtualCoinForContract)(v, state.contract);
561
+ extended.push({ ...extendedVtxo, contractScript });
562
+ }
563
+ catch {
564
+ console.warn("failed to extend vtxo: ", v);
565
+ extended.push({ ...v, contractScript });
566
+ }
567
+ }
532
568
  switch (eventType) {
533
569
  case "vtxo_received":
534
570
  this.eventCallback({
535
571
  type: "vtxo_received",
536
- vtxos: vtxos.map((v) => ({
537
- ...v,
538
- contractScript,
539
- // These fields may not be available from basic VirtualCoin
540
- forfeitTapLeafScript: undefined,
541
- intentTapLeafScript: undefined,
542
- tapTree: undefined,
543
- })),
572
+ vtxos: extended,
544
573
  contractScript,
545
574
  contract: state.contract,
546
575
  timestamp,
@@ -549,14 +578,7 @@ class ContractWatcher {
549
578
  case "vtxo_spent":
550
579
  this.eventCallback({
551
580
  type: "vtxo_spent",
552
- vtxos: vtxos.map((v) => ({
553
- ...v,
554
- contractScript,
555
- // These fields may not be available from basic VirtualCoin
556
- forfeitTapLeafScript: undefined,
557
- intentTapLeafScript: undefined,
558
- tapTree: undefined,
559
- })),
581
+ vtxos: extended,
560
582
  contractScript,
561
583
  contract: state.contract,
562
584
  timestamp,
@@ -236,18 +236,19 @@ class RestArkProvider {
236
236
  // leak the underlying SSE connection. `return()` is overridden below
237
237
  // so that closing the generator also closes the connection even when
238
238
  // the body is currently suspended at an await point.
239
- let eventSource = null;
239
+ let iterator = null;
240
+ const closeIterator = () => iterator?.close();
240
241
  // eslint-disable-next-line @typescript-eslint/no-this-alias
241
242
  const self = this;
242
243
  const gen = (async function* () {
243
- const abortHandler = () => eventSource?.close();
244
+ const abortHandler = closeIterator;
244
245
  signal?.addEventListener("abort", abortHandler);
245
246
  try {
246
247
  while (!signal?.aborted) {
247
- eventSource = new EventSource(url + queryParams);
248
- const iterator = (0, utils_1.eventSourceIterator)(eventSource);
248
+ const currentIterator = (0, utils_1.eventSourceIterator)(new EventSource(url + queryParams));
249
+ iterator = currentIterator;
249
250
  try {
250
- for await (const event of iterator) {
251
+ for await (const event of currentIterator) {
251
252
  if (signal?.aborted)
252
253
  break;
253
254
  try {
@@ -281,71 +282,87 @@ class RestArkProvider {
281
282
  throw error;
282
283
  }
283
284
  finally {
284
- eventSource.close();
285
+ currentIterator.close();
286
+ iterator = null;
285
287
  }
286
288
  }
287
289
  }
288
290
  finally {
289
291
  signal?.removeEventListener("abort", abortHandler);
290
- eventSource?.close();
292
+ closeIterator();
291
293
  }
292
294
  })();
293
295
  const origReturn = gen.return.bind(gen);
294
296
  gen.return = (value) => {
295
- eventSource?.close();
297
+ closeIterator();
296
298
  return origReturn(value);
297
299
  };
298
300
  return gen;
299
301
  }
300
- async *getTransactionsStream(signal) {
302
+ getTransactionsStream(signal) {
301
303
  const url = `${this.serverUrl}/v1/txs`;
302
- while (!signal?.aborted) {
304
+ let iterator = null;
305
+ const closeIterator = () => iterator?.close();
306
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
307
+ const self = this;
308
+ const gen = (async function* () {
309
+ const abortHandler = closeIterator;
310
+ signal?.addEventListener("abort", abortHandler);
303
311
  try {
304
- const eventSource = new EventSource(url);
305
- // Set up abort handling
306
- const abortHandler = () => {
307
- eventSource.close();
308
- };
309
- signal?.addEventListener("abort", abortHandler);
310
- try {
311
- for await (const event of (0, utils_1.eventSourceIterator)(eventSource)) {
312
- if (signal?.aborted)
313
- break;
314
- try {
315
- const data = JSON.parse(event.data);
316
- const txNotification = this.parseTransactionNotification(data);
317
- if (txNotification) {
318
- yield txNotification;
312
+ while (!signal?.aborted) {
313
+ try {
314
+ const currentIterator = (0, utils_1.eventSourceIterator)(new EventSource(url));
315
+ iterator = currentIterator;
316
+ for await (const event of currentIterator) {
317
+ if (signal?.aborted)
318
+ break;
319
+ try {
320
+ const data = JSON.parse(event.data);
321
+ const txNotification = self.parseTransactionNotification(data);
322
+ if (txNotification) {
323
+ yield txNotification;
324
+ }
325
+ }
326
+ catch (err) {
327
+ console.error("Failed to parse transaction notification:", err);
328
+ throw err;
319
329
  }
320
330
  }
321
- catch (err) {
322
- console.error("Failed to parse transaction notification:", err);
323
- throw err;
331
+ }
332
+ catch (error) {
333
+ if (signal?.aborted ||
334
+ (error instanceof Error &&
335
+ error.name === "AbortError")) {
336
+ break;
324
337
  }
338
+ // ignore timeout errors, they're expected when the server is not sending anything for 5 min
339
+ if (isFetchTimeoutError(error)) {
340
+ console.debug("Timeout error ignored");
341
+ continue;
342
+ }
343
+ if ((0, utils_1.isEventSourceError)(error)) {
344
+ throw error;
345
+ }
346
+ console.error("Transaction stream error:", error);
347
+ throw error;
348
+ }
349
+ finally {
350
+ closeIterator();
351
+ iterator = null;
325
352
  }
326
- }
327
- finally {
328
- signal?.removeEventListener("abort", abortHandler);
329
- eventSource.close();
330
353
  }
331
354
  }
332
- catch (error) {
333
- if (signal?.aborted ||
334
- (error instanceof Error && error.name === "AbortError")) {
335
- break;
336
- }
337
- // ignore timeout errors, they're expected when the server is not sending anything for 5 min
338
- if (isFetchTimeoutError(error)) {
339
- console.debug("Timeout error ignored");
340
- continue;
341
- }
342
- if ((0, utils_1.isEventSourceError)(error)) {
343
- throw error;
344
- }
345
- console.error("Transaction stream error:", error);
346
- throw error;
355
+ finally {
356
+ signal?.removeEventListener("abort", abortHandler);
357
+ closeIterator();
347
358
  }
348
- }
359
+ })();
360
+ const origReturn = gen.return.bind(gen);
361
+ gen.return = (value) => {
362
+ closeIterator();
363
+ return origReturn(value);
364
+ };
365
+ return gen;
349
366
  }
350
367
  async getPendingTxs(intent) {
351
368
  const url = `${this.serverUrl}/v1/tx/pending`;
@@ -156,62 +156,75 @@ class RestIndexerProvider {
156
156
  }
157
157
  return data;
158
158
  }
159
- async *getSubscription(subscriptionId, abortSignal) {
159
+ getSubscription(subscriptionId, abortSignal) {
160
160
  const url = `${this.serverUrl}/v1/indexer/script/subscription/${subscriptionId}`;
161
- while (!abortSignal?.aborted) {
161
+ let iterator = null;
162
+ const closeIterator = () => iterator?.close();
163
+ const gen = (async function* () {
164
+ const abortHandler = closeIterator;
165
+ abortSignal?.addEventListener("abort", abortHandler);
162
166
  try {
163
- const eventSource = new EventSource(url);
164
- // Set up abort handling
165
- const abortHandler = () => {
166
- eventSource.close();
167
- };
168
- abortSignal?.addEventListener("abort", abortHandler);
169
- try {
170
- for await (const event of (0, utils_1.eventSourceIterator)(eventSource)) {
171
- if (abortSignal?.aborted)
172
- break;
173
- try {
174
- const data = JSON.parse(event.data);
175
- if (data.event) {
176
- yield {
177
- txid: data.event.txid,
178
- scripts: data.event.scripts || [],
179
- newVtxos: (data.event.newVtxos || []).map(convertVtxo),
180
- spentVtxos: (data.event.spentVtxos || []).map(convertVtxo),
181
- sweptVtxos: (data.event.sweptVtxos || []).map(convertVtxo),
182
- tx: data.event.tx,
183
- checkpointTxs: data.event.checkpointTxs,
184
- };
167
+ while (!abortSignal?.aborted) {
168
+ try {
169
+ const currentIterator = (0, utils_1.eventSourceIterator)(new EventSource(url));
170
+ iterator = currentIterator;
171
+ for await (const event of currentIterator) {
172
+ if (abortSignal?.aborted)
173
+ break;
174
+ try {
175
+ const data = JSON.parse(event.data);
176
+ if (data.event) {
177
+ yield {
178
+ txid: data.event.txid,
179
+ scripts: data.event.scripts || [],
180
+ newVtxos: (data.event.newVtxos || []).map(convertVtxo),
181
+ spentVtxos: (data.event.spentVtxos || []).map(convertVtxo),
182
+ sweptVtxos: (data.event.sweptVtxos || []).map(convertVtxo),
183
+ tx: data.event.tx,
184
+ checkpointTxs: data.event.checkpointTxs,
185
+ };
186
+ }
187
+ }
188
+ catch (err) {
189
+ console.error("Failed to parse subscription event:", err);
190
+ throw err;
185
191
  }
186
192
  }
187
- catch (err) {
188
- console.error("Failed to parse subscription event:", err);
189
- throw err;
193
+ }
194
+ catch (error) {
195
+ if (abortSignal?.aborted ||
196
+ (error instanceof Error &&
197
+ error.name === "AbortError")) {
198
+ break;
190
199
  }
200
+ // ignore timeout errors, they're expected when the server is not sending anything for 5 min
201
+ if ((0, ark_1.isFetchTimeoutError)(error)) {
202
+ console.debug("Timeout error ignored");
203
+ continue;
204
+ }
205
+ if ((0, utils_1.isEventSourceError)(error)) {
206
+ throw error;
207
+ }
208
+ console.error("Subscription error:", error);
209
+ throw error;
210
+ }
211
+ finally {
212
+ closeIterator();
213
+ iterator = null;
191
214
  }
192
- }
193
- finally {
194
- abortSignal?.removeEventListener("abort", abortHandler);
195
- eventSource.close();
196
215
  }
197
216
  }
198
- catch (error) {
199
- if (abortSignal?.aborted ||
200
- (error instanceof Error && error.name === "AbortError")) {
201
- break;
202
- }
203
- // ignore timeout errors, they're expected when the server is not sending anything for 5 min
204
- if ((0, ark_1.isFetchTimeoutError)(error)) {
205
- console.debug("Timeout error ignored");
206
- continue;
207
- }
208
- if ((0, utils_1.isEventSourceError)(error)) {
209
- throw error;
210
- }
211
- console.error("Subscription error:", error);
212
- throw error;
217
+ finally {
218
+ abortSignal?.removeEventListener("abort", abortHandler);
219
+ closeIterator();
213
220
  }
214
- }
221
+ })();
222
+ const origReturn = gen.return.bind(gen);
223
+ gen.return = (value) => {
224
+ closeIterator();
225
+ return origReturn(value);
226
+ };
227
+ return gen;
215
228
  }
216
229
  async getVirtualTxs(txids, opts) {
217
230
  let url = `${this.serverUrl}/v1/indexer/virtualTx/${txids.join(",")}`;
@@ -2,32 +2,70 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.eventSourceIterator = eventSourceIterator;
4
4
  exports.isEventSourceError = isEventSourceError;
5
+ function createAbortError() {
6
+ const error = new Error("EventSource closed");
7
+ error.name = "AbortError";
8
+ return error;
9
+ }
5
10
  /**
6
- * Creates an async iterator over EventSource messages that attaches listeners
7
- * eagerly (at call time) rather than lazily (at first .next() call).
8
- * This ensures events are buffered immediately, preventing race conditions
9
- * where events arrive before iteration begins.
11
+ * Creates a close-aware EventSource async iterator.
12
+ *
13
+ * Listeners attach eagerly so events are buffered before the first next() call.
14
+ * close() closes the EventSource, removes listeners, and wakes any pending
15
+ * next() even when the browser does not emit an error from EventSource.close().
10
16
  */
11
17
  function eventSourceIterator(eventSource) {
12
18
  const messageQueue = [];
13
19
  const errorQueue = [];
14
20
  let messageResolve = null;
15
21
  let errorResolve = null;
22
+ let closed = false;
23
+ let cleanedUp = false;
24
+ const cleanup = () => {
25
+ if (cleanedUp)
26
+ return;
27
+ cleanedUp = true;
28
+ eventSource.removeEventListener("message", messageHandler);
29
+ eventSource.removeEventListener("error", errorHandler);
30
+ };
31
+ const close = () => {
32
+ if (closed)
33
+ return;
34
+ closed = true;
35
+ messageQueue.length = 0;
36
+ errorQueue.length = 0;
37
+ eventSource.close();
38
+ cleanup();
39
+ if (errorResolve) {
40
+ const reject = errorResolve;
41
+ messageResolve = null;
42
+ errorResolve = null;
43
+ reject(createAbortError());
44
+ }
45
+ };
16
46
  const messageHandler = (event) => {
47
+ if (closed)
48
+ return;
17
49
  if (messageResolve) {
18
- messageResolve(event);
50
+ const resolve = messageResolve;
19
51
  messageResolve = null;
52
+ errorResolve = null;
53
+ resolve(event);
20
54
  }
21
55
  else {
22
56
  messageQueue.push(event);
23
57
  }
24
58
  };
25
59
  const errorHandler = () => {
60
+ if (closed)
61
+ return;
26
62
  const error = new Error("EventSource error");
27
63
  error.name = "EventSourceError";
28
64
  if (errorResolve) {
29
- errorResolve(error);
65
+ const reject = errorResolve;
66
+ messageResolve = null;
30
67
  errorResolve = null;
68
+ reject(error);
31
69
  }
32
70
  else {
33
71
  errorQueue.push(error);
@@ -37,9 +75,9 @@ function eventSourceIterator(eventSource) {
37
75
  // even before the caller starts iterating
38
76
  eventSource.addEventListener("message", messageHandler);
39
77
  eventSource.addEventListener("error", errorHandler);
40
- return (async function* () {
78
+ const gen = (async function* () {
41
79
  try {
42
- while (true) {
80
+ while (!closed) {
43
81
  // if we have queued messages, yield the first one, remove it from the queue
44
82
  if (messageQueue.length > 0) {
45
83
  yield messageQueue.shift();
@@ -58,17 +96,25 @@ function eventSourceIterator(eventSource) {
58
96
  messageResolve = null;
59
97
  errorResolve = null;
60
98
  });
61
- if (result) {
99
+ if (!closed && result) {
62
100
  yield result;
63
101
  }
64
102
  }
65
103
  }
66
104
  finally {
67
- // clean up
68
- eventSource.removeEventListener("message", messageHandler);
69
- eventSource.removeEventListener("error", errorHandler);
105
+ closed = true;
106
+ cleanup();
107
+ eventSource.close();
70
108
  }
71
109
  })();
110
+ const origReturn = gen.return.bind(gen);
111
+ const managed = gen;
112
+ managed.close = close;
113
+ managed.return = (value) => {
114
+ close();
115
+ return origReturn(value);
116
+ };
117
+ return managed;
72
118
  }
73
119
  function isEventSourceError(error) {
74
120
  return error instanceof Error && error.name === "EventSourceError";
@@ -29,20 +29,30 @@ class DelegatorManagerImpl {
29
29
  // fetch server and delegator info once, shared across all groups
30
30
  const arkInfo = await this.arkInfoProvider.getInfo();
31
31
  const delegateInfo = await this.delegatorProvider.getDelegateInfo();
32
+ // keep only vtxos that can be signed by the delegate
33
+ const eligible = vtxos
34
+ .filter((v) => findDelegateTapLeaf(v, delegateInfo.pubkey) !== undefined)
35
+ .map((v) => v);
36
+ if (eligible.length === 0) {
37
+ return { delegated: [], failed: [] };
38
+ }
32
39
  // if explicit delegateAt is provided, delegate all virtual outputs at once without sorting
33
40
  if (delegateAt) {
34
41
  try {
35
- await delegate(this.identity, this.delegatorProvider, arkInfo, delegateInfo, vtxos, destinationScript, delegateAt);
42
+ await delegate(this.identity, this.delegatorProvider, arkInfo, delegateInfo, eligible, destinationScript, delegateAt);
36
43
  }
37
44
  catch (error) {
38
- return { delegated: [], failed: [{ outpoints: vtxos, error }] };
45
+ return {
46
+ delegated: [],
47
+ failed: [{ outpoints: eligible, error }],
48
+ };
39
49
  }
40
- return { delegated: vtxos, failed: [] };
50
+ return { delegated: eligible, failed: [] };
41
51
  }
42
52
  // if no explicit delegateAt is provided, sort virtual outputs by expiry and delegate in groups of the same expiry day
43
53
  const groupByExpiry = new Map();
44
54
  let recoverableVtxos = [];
45
- for (const vtxo of vtxos) {
55
+ for (const vtxo of eligible) {
46
56
  if ((0, __1.isRecoverable)(vtxo)) {
47
57
  recoverableVtxos.push(vtxo);
48
58
  continue;
@@ -190,20 +200,7 @@ async function delegate(identity, delegatorProvider, arkInfo, delegateInfo, vtxo
190
200
  await delegatorProvider.delegate(registerIntent, forfeits);
191
201
  }
192
202
  async function makeDelegateForfeitTx(input, connectorAmount, delegatePubkey, forfeitOutputScript, identity) {
193
- if (delegatePubkey.length === 66) {
194
- delegatePubkey = delegatePubkey.slice(2);
195
- }
196
- const vtxoScript = __1.VtxoScript.decode(input.tapTree);
197
- const delegateTapLeaf = vtxoScript.leaves.find((tapLeaf) => {
198
- const arkTapscript = (0, __1.decodeTapscript)((0, base_2.scriptFromTapLeafScript)(tapLeaf));
199
- if (!__1.MultisigTapscript.is(arkTapscript))
200
- return false;
201
- if (!arkTapscript.params.pubkeys
202
- .map(base_1.hex.encode)
203
- .includes(delegatePubkey))
204
- return false;
205
- return true;
206
- });
203
+ const delegateTapLeaf = findDelegateTapLeaf(input, delegatePubkey);
207
204
  if (!delegateTapLeaf) {
208
205
  throw new Error(`delegate tap leaf not found for input: ${input.txid}:${input.vout}`);
209
206
  }
@@ -291,3 +288,15 @@ function getDayTimestamp(timestamp) {
291
288
  date.setUTCHours(0, 0, 0, 0);
292
289
  return date.getTime();
293
290
  }
291
+ function findDelegateTapLeaf(vtxo, delegatePubkey) {
292
+ if (!vtxo.tapTree)
293
+ return undefined;
294
+ const pk = delegatePubkey.length === 66 ? delegatePubkey.slice(2) : delegatePubkey;
295
+ const vtxoScript = __1.VtxoScript.decode(vtxo.tapTree);
296
+ return vtxoScript.leaves.find((tapLeaf) => {
297
+ const arkTapscript = (0, __1.decodeTapscript)((0, base_2.scriptFromTapLeafScript)(tapLeaf));
298
+ if (!__1.MultisigTapscript.is(arkTapscript))
299
+ return false;
300
+ return arkTapscript.params.pubkeys.map(base_1.hex.encode).includes(pk);
301
+ });
302
+ }
@@ -706,7 +706,9 @@ class WalletMessageHandler {
706
706
  const { vtxoOutpoints, destination, delegateAt } = message.payload;
707
707
  const allVtxos = await wallet.getVtxos();
708
708
  const outpointSet = new Set(vtxoOutpoints.map((o) => `${o.txid}:${o.vout}`));
709
- const filtered = allVtxos.filter((v) => outpointSet.has(`${v.txid}:${v.vout}`));
709
+ const filtered = allVtxos
710
+ .filter((v) => outpointSet.has(`${v.txid}:${v.vout}`))
711
+ .map((v) => ({ ...v, contractScript: v.script }));
710
712
  const result = await delegatorManager.delegate(filtered, destination, delegateAt !== undefined ? new Date(delegateAt) : undefined);
711
713
  return {
712
714
  tag: this.messageTag,
@@ -698,11 +698,13 @@ class VtxoManager {
698
698
  console.error("Error renewing VTXOs:", e);
699
699
  });
700
700
  }
701
- delegatorManager
702
- ?.delegate(event.vtxos, destination)
703
- .catch((e) => {
704
- console.error("Error delegating VTXOs:", e);
705
- });
701
+ if (delegatorManager) {
702
+ delegatorManager
703
+ .delegate(event.vtxos, destination)
704
+ .catch((e) => {
705
+ console.error("Error delegating VTXOs:", e);
706
+ });
707
+ }
706
708
  });
707
709
  return stopWatching;
708
710
  }