@arkade-os/sdk 0.4.19 → 0.4.21

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 (61) hide show
  1. package/dist/cjs/contracts/contractWatcher.js +33 -3
  2. package/dist/cjs/contracts/handlers/default.js +10 -3
  3. package/dist/cjs/contracts/handlers/helpers.js +47 -5
  4. package/dist/cjs/contracts/handlers/vhtlc.js +4 -2
  5. package/dist/cjs/identity/descriptor.js +98 -0
  6. package/dist/cjs/identity/descriptorProvider.js +2 -0
  7. package/dist/cjs/identity/index.js +15 -1
  8. package/dist/cjs/identity/seedIdentity.js +91 -6
  9. package/dist/cjs/identity/serialize.js +166 -0
  10. package/dist/cjs/identity/staticDescriptorProvider.js +65 -0
  11. package/dist/cjs/index.js +6 -3
  12. package/dist/cjs/providers/ark.js +71 -46
  13. package/dist/cjs/providers/electrum.js +663 -0
  14. package/dist/cjs/providers/indexer.js +60 -43
  15. package/dist/cjs/providers/utils.js +62 -12
  16. package/dist/cjs/wallet/ramps.js +1 -1
  17. package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +10 -0
  18. package/dist/cjs/wallet/serviceWorker/wallet.js +137 -91
  19. package/dist/cjs/wallet/vtxo-manager.js +56 -8
  20. package/dist/cjs/wallet/wallet.js +130 -156
  21. package/dist/cjs/worker/messageBus.js +200 -56
  22. package/dist/esm/contracts/contractWatcher.js +33 -3
  23. package/dist/esm/contracts/handlers/default.js +10 -3
  24. package/dist/esm/contracts/handlers/helpers.js +47 -5
  25. package/dist/esm/contracts/handlers/vhtlc.js +4 -2
  26. package/dist/esm/identity/descriptor.js +92 -0
  27. package/dist/esm/identity/descriptorProvider.js +1 -0
  28. package/dist/esm/identity/index.js +6 -1
  29. package/dist/esm/identity/seedIdentity.js +89 -6
  30. package/dist/esm/identity/serialize.js +159 -0
  31. package/dist/esm/identity/staticDescriptorProvider.js +61 -0
  32. package/dist/esm/index.js +2 -1
  33. package/dist/esm/providers/ark.js +72 -47
  34. package/dist/esm/providers/electrum.js +658 -0
  35. package/dist/esm/providers/indexer.js +61 -44
  36. package/dist/esm/providers/utils.js +61 -12
  37. package/dist/esm/wallet/ramps.js +1 -1
  38. package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +10 -0
  39. package/dist/esm/wallet/serviceWorker/wallet.js +137 -91
  40. package/dist/esm/wallet/vtxo-manager.js +56 -8
  41. package/dist/esm/wallet/wallet.js +130 -156
  42. package/dist/esm/worker/messageBus.js +201 -57
  43. package/dist/types/contracts/contractWatcher.d.ts +3 -0
  44. package/dist/types/contracts/handlers/default.d.ts +1 -1
  45. package/dist/types/contracts/handlers/helpers.d.ts +1 -1
  46. package/dist/types/contracts/types.d.ts +11 -3
  47. package/dist/types/identity/descriptor.d.ts +35 -0
  48. package/dist/types/identity/descriptorProvider.d.ts +28 -0
  49. package/dist/types/identity/index.d.ts +7 -1
  50. package/dist/types/identity/seedIdentity.d.ts +41 -4
  51. package/dist/types/identity/serialize.d.ts +84 -0
  52. package/dist/types/identity/staticDescriptorProvider.d.ts +18 -0
  53. package/dist/types/index.d.ts +4 -2
  54. package/dist/types/providers/electrum.d.ts +212 -0
  55. package/dist/types/providers/utils.d.ts +10 -5
  56. package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +11 -2
  57. package/dist/types/wallet/serviceWorker/wallet.d.ts +27 -10
  58. package/dist/types/wallet/vtxo-manager.d.ts +2 -0
  59. package/dist/types/wallet/wallet.d.ts +7 -6
  60. package/dist/types/worker/messageBus.d.ts +68 -8
  61. package/package.json +3 -2
@@ -7,13 +7,20 @@ const ark_1 = require("../providers/ark");
7
7
  const delegator_1 = require("../providers/delegator");
8
8
  const identity_1 = require("../identity");
9
9
  const wallet_1 = require("../wallet/wallet");
10
- const base_1 = require("@scure/base");
11
10
  const errors_1 = require("./errors");
11
+ /**
12
+ * Grace period after a handler times out during which late handler
13
+ * completion is still delivered to the client. Once this expires,
14
+ * the bus sends an "Operation abandoned" error so the message id
15
+ * never goes silent indefinitely.
16
+ */
17
+ const LATE_DELIVERY_GRACE_MS = 5 * 60000;
12
18
  class MessageBus {
13
19
  /** Create the service-worker message bus with repositories and handler configuration. */
14
- constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, messageTimeoutMs = 30000, debug = false, buildServices, }) {
20
+ constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, messageTimeoutMs = 30000, messageTimeoutOverrides = {}, debug = false, buildServices, }) {
15
21
  this.walletRepository = walletRepository;
16
22
  this.contractRepository = contractRepository;
23
+ this.lateDeliveries = new Set();
17
24
  this.running = false;
18
25
  this.tickTimeout = null;
19
26
  this.tickInProgress = false;
@@ -23,6 +30,8 @@ class MessageBus {
23
30
  this.handlers = new Map(messageHandlers.map((u) => [u.messageTag, u]));
24
31
  this.tickIntervalMs = tickIntervalMs;
25
32
  this.messageTimeoutMs = messageTimeoutMs;
33
+ this.constructorTimeoutOverrides = { ...messageTimeoutOverrides };
34
+ this.messageTimeoutOverrides = { ...this.constructorTimeoutOverrides };
26
35
  this.debug = debug;
27
36
  this.buildServicesFn = buildServices ?? this.buildServices.bind(this);
28
37
  }
@@ -58,6 +67,11 @@ class MessageBus {
58
67
  self.clearTimeout(this.tickTimeout);
59
68
  this.tickTimeout = null;
60
69
  }
70
+ for (const record of this.lateDeliveries) {
71
+ record.settled = true;
72
+ self.clearTimeout(record.deadline);
73
+ }
74
+ this.lateDeliveries.clear();
61
75
  self.removeEventListener("message", this.boundOnMessage);
62
76
  await Promise.all(Array.from(this.handlers.values()).map((updater) => updater.stop()));
63
77
  }
@@ -84,7 +98,8 @@ class MessageBus {
84
98
  const now = Date.now();
85
99
  for (const updater of this.handlers.values()) {
86
100
  try {
87
- const response = await this.withTimeout(updater.tick(now), `${updater.messageTag}:tick`);
101
+ const tickLabel = `${updater.messageTag}:tick`;
102
+ const response = await this.withTimeout(updater.tick(now), this.resolveTimeoutMs(tickLabel, updater.messageTag), tickLabel);
88
103
  if (this.debug)
89
104
  console.log(`[${updater.messageTag}] outgoing tick response:`, response);
90
105
  if (response && response.length > 0) {
@@ -127,6 +142,12 @@ class MessageBus {
127
142
  this.initialized = false;
128
143
  await Promise.all(Array.from(this.handlers.values()).map((h) => h.stop().catch(() => { })));
129
144
  }
145
+ // Recompute the active timeout map from scratch so a prior init's
146
+ // keys cannot linger after re-init with a smaller map.
147
+ this.messageTimeoutOverrides = {
148
+ ...this.constructorTimeoutOverrides,
149
+ ...(config.messageTimeouts ?? {}),
150
+ };
130
151
  const services = await this.buildServicesFn(config);
131
152
  // Start all handlers
132
153
  for (const updater of this.handlers.values()) {
@@ -149,8 +170,9 @@ class MessageBus {
149
170
  const delegatorProvider = config.delegatorUrl
150
171
  ? new delegator_1.RestDelegatorProvider(config.delegatorUrl)
151
172
  : undefined;
152
- if ("privateKey" in config.wallet) {
153
- const identity = identity_1.SingleKey.fromHex(config.wallet.privateKey);
173
+ const serialized = (0, identity_1.normalizeSerializedIdentity)(config.wallet);
174
+ if ((0, identity_1.isSigningSerialized)(serialized)) {
175
+ const identity = (0, identity_1.hydrateIdentity)(serialized);
154
176
  const wallet = await wallet_1.Wallet.create({
155
177
  identity,
156
178
  arkServerUrl: config.arkServer.url,
@@ -164,23 +186,18 @@ class MessageBus {
164
186
  });
165
187
  return { wallet, arkProvider, readonlyWallet: wallet };
166
188
  }
167
- else if ("publicKey" in config.wallet) {
168
- const identity = identity_1.ReadonlySingleKey.fromPublicKey(base_1.hex.decode(config.wallet.publicKey));
169
- const readonlyWallet = await wallet_1.ReadonlyWallet.create({
170
- identity,
171
- arkServerUrl: config.arkServer.url,
172
- arkServerPublicKey: config.arkServer.publicKey,
173
- indexerUrl: config.indexerUrl,
174
- esploraUrl: config.esploraUrl,
175
- storage,
176
- delegatorProvider,
177
- watcherConfig: config.watcherConfig,
178
- });
179
- return { readonlyWallet, arkProvider };
180
- }
181
- else {
182
- throw new Error("Missing privateKey or publicKey in configuration object");
183
- }
189
+ const identity = (0, identity_1.hydrateIdentity)(serialized);
190
+ const readonlyWallet = await wallet_1.ReadonlyWallet.create({
191
+ identity,
192
+ arkServerUrl: config.arkServer.url,
193
+ arkServerPublicKey: config.arkServer.publicKey,
194
+ indexerUrl: config.indexerUrl,
195
+ esploraUrl: config.esploraUrl,
196
+ storage,
197
+ delegatorProvider,
198
+ watcherConfig: config.watcherConfig,
199
+ });
200
+ return { readonlyWallet, arkProvider };
184
201
  }
185
202
  onMessage(event) {
186
203
  // Keep the service worker alive while async work is pending.
@@ -195,7 +212,7 @@ class MessageBus {
195
212
  async processMessage(event) {
196
213
  const { id, tag, broadcast } = event.data;
197
214
  if (tag === "PING") {
198
- event.source?.postMessage({ id, tag: "PONG" });
215
+ this.deliverResponse(event.source, { id, tag: "PONG" }, { id, tag: "PONG" });
199
216
  return;
200
217
  }
201
218
  if (tag === "INITIALIZE_MESSAGE_BUS") {
@@ -206,7 +223,7 @@ class MessageBus {
206
223
  // performs network calls (buildServices) and handler startup
207
224
  // that may legitimately exceed the message timeout.
208
225
  await this.waitForInit(event.data.config);
209
- event.source?.postMessage({ id, tag });
226
+ this.deliverResponse(event.source, { id, tag }, { id, tag });
210
227
  if (this.debug) {
211
228
  console.log("MessageBus initialized");
212
229
  }
@@ -219,45 +236,60 @@ class MessageBus {
219
236
  // hanging forever. This happens when the browser kills and restarts
220
237
  // the service worker — the new instance has initialized=false and
221
238
  // messages arrive before INITIALIZE_MESSAGE_BUS is re-sent.
222
- event.source?.postMessage({
239
+ const fallbackTag = tag ?? "unknown";
240
+ this.deliverResponse(event.source, {
223
241
  id,
224
- tag: tag ?? "unknown",
242
+ tag: fallbackTag,
225
243
  error: new errors_1.MessageBusNotInitializedError(),
226
- });
244
+ }, { id, tag: fallbackTag });
227
245
  return;
228
246
  }
229
247
  if (!id || !tag) {
230
248
  if (this.debug)
231
249
  console.error("Invalid message received, missing required fields:", event.data);
232
- event.source?.postMessage({
250
+ const fallbackTag = tag ?? "unknown";
251
+ this.deliverResponse(event.source, {
233
252
  id,
234
- tag: tag ?? "unknown",
253
+ tag: fallbackTag,
235
254
  error: new TypeError("Invalid message received, missing required fields"),
236
- });
255
+ }, { id, tag: fallbackTag });
237
256
  return;
238
257
  }
258
+ const messageType = this.extractMessageType(event.data);
239
259
  if (broadcast) {
240
260
  const updaters = Array.from(this.handlers.values());
241
- const results = await Promise.allSettled(updaters.map((updater) => this.withTimeout(updater.handleMessage(event.data), updater.messageTag)));
261
+ const entries = updaters.map((updater) => {
262
+ const label = this.labelFor(messageType, updater.messageTag);
263
+ const timeoutMs = this.resolveTimeoutMs(messageType, updater.messageTag);
264
+ const handlerPromise = updater.handleMessage(event.data);
265
+ const raced = updater.isLongRunning?.(event.data)
266
+ ? handlerPromise
267
+ : this.withTimeout(handlerPromise, timeoutMs, label);
268
+ return { updater, handlerPromise, raced };
269
+ });
270
+ const results = await Promise.allSettled(entries.map((e) => e.raced));
242
271
  results.forEach((result, index) => {
243
- const updater = updaters[index];
272
+ const { updater, handlerPromise } = entries[index];
273
+ const handlerTag = updater.messageTag;
274
+ const context = { id, tag: handlerTag, messageType };
244
275
  if (result.status === "fulfilled") {
245
276
  const response = result.value;
246
- if (response) {
247
- event.source?.postMessage(response);
248
- }
277
+ // Always deliver a response so the caller's message id
278
+ // never goes silent. Handlers returning null/undefined
279
+ // get an explicit ack envelope.
280
+ this.deliverResponse(event.source, response ?? { id, tag: handlerTag }, context);
249
281
  }
250
282
  else {
251
283
  if (this.debug)
252
- console.error(`[${updater.messageTag}] handleMessage failed`, result.reason);
253
- const error = result.reason instanceof Error
254
- ? result.reason
255
- : new Error(String(result.reason));
256
- event.source?.postMessage({
257
- id,
258
- tag: updater.messageTag,
259
- error,
260
- });
284
+ console.error(`[${handlerTag}] handleMessage failed`, result.reason);
285
+ const error = toError(result.reason);
286
+ this.deliverResponse(event.source, { id, tag: handlerTag, error }, context);
287
+ // If the error was a timeout, keep watching the
288
+ // underlying handler and surface its eventual result
289
+ // under the same id.
290
+ if (result.reason instanceof errors_1.ServiceWorkerTimeoutError) {
291
+ this.attachLateDelivery(handlerPromise, event.source, id, handlerTag, messageType);
292
+ }
261
293
  }
262
294
  });
263
295
  return;
@@ -266,35 +298,53 @@ class MessageBus {
266
298
  if (!updater) {
267
299
  if (this.debug)
268
300
  console.warn(`[${tag}] unknown message tag, ignoring message`);
301
+ this.deliverResponse(event.source, {
302
+ id,
303
+ tag,
304
+ error: new Error(`Unknown handler tag: ${tag}`),
305
+ }, { id, tag, messageType });
269
306
  return;
270
307
  }
308
+ const label = this.labelFor(messageType, tag);
309
+ const timeoutMs = this.resolveTimeoutMs(messageType, tag);
310
+ const handlerPromise = updater.handleMessage(event.data);
311
+ const context = { id, tag, messageType };
271
312
  try {
272
- const response = await this.withTimeout(updater.handleMessage(event.data), tag);
313
+ const response = updater.isLongRunning?.(event.data)
314
+ ? await handlerPromise
315
+ : await this.withTimeout(handlerPromise, timeoutMs, label);
273
316
  if (this.debug)
274
317
  console.log(`[${tag}] outgoing response:`, response);
275
- if (response) {
276
- event.source?.postMessage(response);
277
- }
318
+ // Always deliver a response so the caller's message id never
319
+ // goes silent. A handler returning null/undefined yields an
320
+ // explicit ack envelope.
321
+ this.deliverResponse(event.source, response ?? { id, tag }, context);
278
322
  }
279
323
  catch (err) {
280
324
  if (this.debug)
281
325
  console.error(`[${tag}] handleMessage failed`, err);
282
- const error = err instanceof Error ? err : new Error(String(err));
283
- event.source?.postMessage({ id, tag, error });
326
+ const error = toError(err);
327
+ this.deliverResponse(event.source, { id, tag, error }, context);
328
+ // When we abandoned the handler via timeout, keep watching it
329
+ // so the client's message id eventually gets a final response.
330
+ if (err instanceof errors_1.ServiceWorkerTimeoutError) {
331
+ this.attachLateDelivery(handlerPromise, event.source, id, tag, messageType);
332
+ }
284
333
  }
285
334
  }
286
335
  /**
287
336
  * Race `promise` against a timeout. Note: this does NOT cancel the
288
- * underlying work — the original promise keeps running. This is safe
289
- * here because only the caller (not the handler) posts the response.
337
+ * underlying work — the original promise keeps running. Call
338
+ * `attachLateDelivery` after catching the timeout to surface the
339
+ * eventual result so the message id does not go silent.
290
340
  */
291
- withTimeout(promise, label) {
292
- if (this.messageTimeoutMs <= 0)
341
+ withTimeout(promise, timeoutMs, label) {
342
+ if (timeoutMs <= 0)
293
343
  return promise;
294
344
  return new Promise((resolve, reject) => {
295
345
  const timer = self.setTimeout(() => {
296
- reject(new errors_1.ServiceWorkerTimeoutError(`Message handler timed out after ${this.messageTimeoutMs}ms (${label})`));
297
- }, this.messageTimeoutMs);
346
+ reject(new errors_1.ServiceWorkerTimeoutError(`Message handler timed out after ${timeoutMs}ms (${label})`));
347
+ }, timeoutMs);
298
348
  promise.then((val) => {
299
349
  self.clearTimeout(timer);
300
350
  resolve(val);
@@ -304,6 +354,97 @@ class MessageBus {
304
354
  });
305
355
  });
306
356
  }
357
+ /**
358
+ * Extract the declared `type` from a request envelope (e.g. "SETTLE").
359
+ * Not every envelope carries a type (PING/INIT are special cased
360
+ * earlier), so this returns undefined for envelopes that lack one.
361
+ */
362
+ extractMessageType(data) {
363
+ const maybeType = data.type;
364
+ return typeof maybeType === "string" ? maybeType : undefined;
365
+ }
366
+ /**
367
+ * Resolve the timeout for an operation. Message-type overrides take
368
+ * precedence over handler-tag overrides, with the bus-wide default
369
+ * (`messageTimeoutMs`) as the final fallback.
370
+ */
371
+ resolveTimeoutMs(messageType, handlerTag) {
372
+ if (messageType &&
373
+ Object.prototype.hasOwnProperty.call(this.messageTimeoutOverrides, messageType)) {
374
+ return this.messageTimeoutOverrides[messageType];
375
+ }
376
+ if (Object.prototype.hasOwnProperty.call(this.messageTimeoutOverrides, handlerTag)) {
377
+ return this.messageTimeoutOverrides[handlerTag];
378
+ }
379
+ return this.messageTimeoutMs;
380
+ }
381
+ /**
382
+ * Build a human-readable label for timeout errors. Format:
383
+ * `"<MESSAGE_TYPE> via <HANDLER_TAG>"` when both are known, else the
384
+ * handler tag alone. Used so timeout errors name the operation the
385
+ * client actually triggered (e.g. SETTLE) rather than just the
386
+ * handler that received it (e.g. WALLET_UPDATER).
387
+ */
388
+ labelFor(messageType, handlerTag) {
389
+ return messageType ? `${messageType} via ${handlerTag}` : handlerTag;
390
+ }
391
+ /**
392
+ * Post a response to the originating client. When `source` is null
393
+ * (client tab closed, detached frame, etc.) the response cannot be
394
+ * delivered; we log the drop in debug mode so it is not invisible.
395
+ */
396
+ deliverResponse(source, response, context) {
397
+ if (!source) {
398
+ if (this.debug)
399
+ console.warn(`[${context.tag}] cannot deliver response: event.source is null`, {
400
+ id: context.id,
401
+ messageType: context.messageType,
402
+ });
403
+ return;
404
+ }
405
+ source.postMessage(response);
406
+ }
407
+ /**
408
+ * After a handler times out the client has already received a timeout
409
+ * error, but the handler keeps running. Attach a follow-up so the
410
+ * handler's eventual result (or error) is delivered under the same
411
+ * message id, or — if the handler never completes within
412
+ * {@link LATE_DELIVERY_GRACE_MS} — an "Operation abandoned" error is
413
+ * sent so the client's listener (if still attached) does not hang.
414
+ */
415
+ attachLateDelivery(handlerPromise, source, id, tag, messageType) {
416
+ const context = { id, tag, messageType };
417
+ const record = {
418
+ settled: false,
419
+ deadline: self.setTimeout(() => {
420
+ if (record.settled)
421
+ return;
422
+ record.settled = true;
423
+ this.lateDeliveries.delete(record);
424
+ this.deliverResponse(source, {
425
+ id,
426
+ tag,
427
+ error: new Error(`Operation abandoned: handler did not complete within ${LATE_DELIVERY_GRACE_MS}ms after timeout (${this.labelFor(messageType, tag)})`),
428
+ }, context);
429
+ }, LATE_DELIVERY_GRACE_MS),
430
+ };
431
+ this.lateDeliveries.add(record);
432
+ handlerPromise.then((response) => {
433
+ if (record.settled)
434
+ return;
435
+ record.settled = true;
436
+ self.clearTimeout(record.deadline);
437
+ this.lateDeliveries.delete(record);
438
+ this.deliverResponse(source, response ?? { id, tag }, context);
439
+ }, (err) => {
440
+ if (record.settled)
441
+ return;
442
+ record.settled = true;
443
+ self.clearTimeout(record.deadline);
444
+ this.lateDeliveries.delete(record);
445
+ this.deliverResponse(source, { id, tag, error: toError(err) }, context);
446
+ });
447
+ }
307
448
  /**
308
449
  * Returns the registered SW for the path.
309
450
  * It uses the functions in `service-worker-manager.ts` module.
@@ -327,3 +468,6 @@ class MessageBus {
327
468
  }
328
469
  }
329
470
  exports.MessageBus = MessageBus;
471
+ function toError(value) {
472
+ return value instanceof Error ? value : new Error(String(value));
473
+ }
@@ -1,3 +1,4 @@
1
+ import { isEventSourceError } from '../providers/utils.js';
1
2
  /**
2
3
  * Watches multiple contracts for virtual output state changes with resilient connection handling.
3
4
  *
@@ -250,13 +251,18 @@ export class ContractWatcher {
250
251
  }
251
252
  /**
252
253
  * Connect to the subscription.
254
+ *
255
+ * @param skipUpdate - Skip the leading `updateSubscription` call when
256
+ * the caller has already established `subscriptionId`.
253
257
  */
254
- async connect() {
258
+ async connect(skipUpdate = false) {
255
259
  if (!this.isWatching)
256
260
  return;
257
261
  this.connectionState = "connecting";
258
262
  try {
259
- await this.updateSubscription();
263
+ if (!skipUpdate) {
264
+ await this.updateSubscription();
265
+ }
260
266
  // Poll immediately after connection to sync state
261
267
  await this.pollAllContracts();
262
268
  this.connectionState = "connected";
@@ -267,7 +273,12 @@ export class ContractWatcher {
267
273
  // indefinitely and block the caller.
268
274
  // Error management must be implemented to ensure the connection
269
275
  // is restored and events are fired.
270
- console.error(e);
276
+ if (isEventSourceError(e)) {
277
+ console.debug("ContractWatcher subscription disconnected; reconnecting");
278
+ }
279
+ else {
280
+ console.error(e);
281
+ }
271
282
  this.connectionState = "disconnected";
272
283
  this.eventCallback?.({
273
284
  type: "connection_reset",
@@ -382,11 +393,30 @@ export class ContractWatcher {
382
393
  }
383
394
  }
384
395
  async tryUpdateSubscription() {
396
+ const hadSubscription = this.subscriptionId !== undefined;
385
397
  try {
386
398
  await this.updateSubscription();
387
399
  }
388
400
  catch (error) {
389
401
  // nothing, the connection will be retried later
402
+ return;
403
+ }
404
+ // Cold start: `startWatching` may have run with zero scripts,
405
+ // leaving `listenLoop` parked behind the reconnect timer. Kick
406
+ // `connect` now so streaming resumes without waiting on the
407
+ // backoff. `skipUpdate` avoids re-issuing `subscribeForScripts`.
408
+ const justGotSubscription = !hadSubscription && this.subscriptionId !== undefined;
409
+ const listenerParked = this.connectionState === "disconnected" ||
410
+ this.connectionState === "reconnecting";
411
+ if (this.isWatching && justGotSubscription && listenerParked) {
412
+ if (this.reconnectTimeoutId) {
413
+ clearTimeout(this.reconnectTimeoutId);
414
+ this.reconnectTimeoutId = undefined;
415
+ }
416
+ this.reconnectAttempts = 0;
417
+ this.connect(true).catch((error) => {
418
+ console.warn("ContractWatcher cold-start connect failed:", error);
419
+ });
390
420
  }
391
421
  }
392
422
  /**
@@ -1,8 +1,15 @@
1
1
  import { hex } from "@scure/base";
2
2
  import { DefaultVtxo } from '../../script/default.js';
3
3
  import { isCsvSpendable, sequenceToTimelock, timelockToSequence, } from './helpers.js';
4
+ import { normalizeToDescriptor, extractPubKey, } from '../../identity/descriptor.js';
4
5
  /**
5
- * Handler for default wallet virtual outputs.
6
+ * Extract pubkey bytes from a descriptor or hex string.
7
+ */
8
+ function extractPubKeyBytes(value) {
9
+ return hex.decode(extractPubKey(normalizeToDescriptor(value)));
10
+ }
11
+ /**
12
+ * Handler for default wallet VTXOs.
6
13
  *
7
14
  * Default contracts use the standard forfeit + exit tapscript:
8
15
  * - forfeit: (Alice + Server) multisig for collaborative spending
@@ -26,8 +33,8 @@ export const DefaultContractHandler = {
26
33
  ? sequenceToTimelock(Number(params.csvTimelock))
27
34
  : DefaultVtxo.Script.DEFAULT_TIMELOCK;
28
35
  return {
29
- pubKey: hex.decode(params.pubKey),
30
- serverPubKey: hex.decode(params.serverPubKey),
36
+ pubKey: extractPubKeyBytes(params.pubKey),
37
+ serverPubKey: extractPubKeyBytes(params.serverPubKey),
31
38
  csvTimelock,
32
39
  };
33
40
  },
@@ -1,4 +1,21 @@
1
1
  import * as bip68 from "bip68";
2
+ import { isDescriptor, extractPubKey } from '../../identity/descriptor.js';
3
+ /**
4
+ * Extract raw hex pubkey from a value that may be a descriptor or raw hex.
5
+ * Returns undefined for HD descriptors or unparseable values so role
6
+ * resolution stays best-effort and never throws.
7
+ */
8
+ function extractRawPubKey(value) {
9
+ if (!isDescriptor(value)) {
10
+ return value.toLowerCase();
11
+ }
12
+ try {
13
+ return extractPubKey(value).toLowerCase();
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
2
19
  /**
3
20
  * Convert RelativeTimelock to BIP68 sequence number.
4
21
  */
@@ -21,21 +38,46 @@ export function sequenceToTimelock(sequence) {
21
38
  throw new Error(`Invalid BIP68 sequence: ${sequence}`);
22
39
  }
23
40
  /**
24
- * Resolve wallet's role from explicit role or by matching pubkey.
41
+ * Resolve wallet's role from explicit role or by matching descriptor/pubkey.
25
42
  */
26
43
  export function resolveRole(contract, context) {
27
44
  // Explicit role takes precedence
28
45
  if (context.role === "sender" || context.role === "receiver") {
29
46
  return context.role;
30
47
  }
31
- // Try to match wallet pubkey against contract params
32
- if (context.walletPubKey) {
33
- if (context.walletPubKey === contract.params.sender) {
48
+ const senderKey = contract.params.sender
49
+ ? extractRawPubKey(contract.params.sender)
50
+ : undefined;
51
+ const receiverKey = contract.params.receiver
52
+ ? extractRawPubKey(contract.params.receiver)
53
+ : undefined;
54
+ const matchRole = (rawWalletKey) => {
55
+ if (!rawWalletKey)
56
+ return undefined;
57
+ if (senderKey && rawWalletKey === senderKey) {
34
58
  return "sender";
35
59
  }
36
- if (context.walletPubKey === contract.params.receiver) {
60
+ if (receiverKey && rawWalletKey === receiverKey) {
37
61
  return "receiver";
38
62
  }
63
+ return undefined;
64
+ };
65
+ // Try the preferred descriptor first. If it cannot be resolved
66
+ // (for example an HD descriptor without derivation support), fall back
67
+ // to walletPubKey for backward compatibility.
68
+ if (context.walletDescriptor) {
69
+ const walletDescriptorKey = extractRawPubKey(context.walletDescriptor);
70
+ const matchedRole = matchRole(walletDescriptorKey);
71
+ if (matchedRole) {
72
+ return matchedRole;
73
+ }
74
+ if (!walletDescriptorKey && context.walletPubKey) {
75
+ return matchRole(extractRawPubKey(context.walletPubKey));
76
+ }
77
+ return undefined;
78
+ }
79
+ if (context.walletPubKey) {
80
+ return matchRole(extractRawPubKey(context.walletPubKey));
39
81
  }
40
82
  return undefined;
41
83
  }
@@ -49,7 +49,8 @@ export const VHTLCContractHandler = {
49
49
  /**
50
50
  * Select spending path based on context.
51
51
  *
52
- * Role is determined from `context.role` or by matching `context.walletPubKey`
52
+ * Role is determined from `context.role` or by matching
53
+ * `context.walletDescriptor` (preferred) / `context.walletPubKey`
53
54
  * against sender/receiver in contract params.
54
55
  */
55
56
  selectPath(script, contract, context) {
@@ -98,7 +99,8 @@ export const VHTLCContractHandler = {
98
99
  /**
99
100
  * Get all possible spending paths (no timelock checks).
100
101
  *
101
- * Role is determined from `context.role` or by matching `context.walletPubKey`
102
+ * Role is determined from `context.role` or by matching
103
+ * `context.walletDescriptor` (preferred) / `context.walletPubKey`
102
104
  * against sender/receiver in contract params.
103
105
  */
104
106
  getAllSpendingPaths(script, contract, context) {
@@ -0,0 +1,92 @@
1
+ import { expand, networks, } from "@bitcoinerlab/descriptors-scure";
2
+ import { hex } from "@scure/base";
3
+ function inferNetwork(descriptor) {
4
+ return descriptor.includes("tpub") ? networks.testnet : networks.bitcoin;
5
+ }
6
+ /**
7
+ * Check if a string is a descriptor of the shape `tr(...)`.
8
+ *
9
+ * This is a shape check only — it does not validate the inner key material.
10
+ * Use {@link expand} (via {@link extractPubKey} / {@link parseHDDescriptor})
11
+ * for full parsing. The guard rejects empty bodies and missing/trailing
12
+ * parentheses so callers can safely branch on descriptor vs. raw pubkey.
13
+ */
14
+ export function isDescriptor(value) {
15
+ if (typeof value !== "string")
16
+ return false;
17
+ if (!value.startsWith("tr(") || !value.endsWith(")"))
18
+ return false;
19
+ // body length > 0 after stripping "tr(" and ")"
20
+ return value.length > "tr()".length;
21
+ }
22
+ /**
23
+ * Normalize a value to descriptor format.
24
+ * If already a descriptor, return as-is. If hex pubkey, wrap as tr(pubkey).
25
+ * Throws when the value is empty or not a string so we never produce
26
+ * malformed descriptors like `tr()` that downstream parsers would reject.
27
+ */
28
+ export function normalizeToDescriptor(value) {
29
+ if (typeof value !== "string" || value.length === 0) {
30
+ throw new Error("normalizeToDescriptor: expected a non-empty string value");
31
+ }
32
+ if (isDescriptor(value)) {
33
+ return value;
34
+ }
35
+ return `tr(${value})`;
36
+ }
37
+ /**
38
+ * Extract the public key from a simple descriptor.
39
+ * For simple descriptors (tr(pubkey)), extracts the pubkey using the library.
40
+ * For HD descriptors, throws — use DescriptorProvider to derive the key.
41
+ */
42
+ export function extractPubKey(descriptor) {
43
+ if (!isDescriptor(descriptor)) {
44
+ return descriptor;
45
+ }
46
+ const network = inferNetwork(descriptor);
47
+ const expansion = expand({ descriptor, network });
48
+ if (!expansion.expansionMap) {
49
+ throw new Error("Cannot extract pubkey from descriptor: expansion failed.");
50
+ }
51
+ const key = expansion.expansionMap["@0"];
52
+ // HD descriptors (have a bip32 key) require DescriptorProvider for derivation
53
+ if (key?.bip32) {
54
+ throw new Error("Cannot extract pubkey from HD descriptor without derivation. " +
55
+ "Use DescriptorProvider to derive the key from the xpub.");
56
+ }
57
+ if (!key?.pubkey) {
58
+ throw new Error("Cannot extract pubkey from descriptor: no key found.");
59
+ }
60
+ return hex.encode(key.pubkey);
61
+ }
62
+ /**
63
+ * Parse an HD descriptor into its components.
64
+ * HD descriptors have the format: tr([fingerprint/path']xpub/derivation)
65
+ * Returns null if the descriptor is not in HD format.
66
+ */
67
+ export function parseHDDescriptor(descriptor) {
68
+ if (!isDescriptor(descriptor)) {
69
+ return null;
70
+ }
71
+ let expansion;
72
+ try {
73
+ const network = inferNetwork(descriptor);
74
+ expansion = expand({ descriptor, network });
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ const key = expansion.expansionMap?.["@0"];
80
+ if (!key?.masterFingerprint ||
81
+ !key.originPath ||
82
+ !key.keyPath ||
83
+ !key.bip32) {
84
+ return null;
85
+ }
86
+ return {
87
+ fingerprint: hex.encode(key.masterFingerprint),
88
+ basePath: key.originPath.replace(/^\//, ""),
89
+ xpub: key.bip32.toBase58(),
90
+ derivationPath: key.keyPath.replace(/^\//, ""),
91
+ };
92
+ }
@@ -0,0 +1 @@
1
+ export {};