@arkade-os/sdk 0.4.18 → 0.4.20

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 (60) hide show
  1. package/dist/cjs/contracts/contractWatcher.js +7 -1
  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 +45 -34
  13. package/dist/cjs/providers/electrum.js +663 -0
  14. package/dist/cjs/providers/indexer.js +5 -1
  15. package/dist/cjs/providers/utils.js +4 -0
  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 +133 -17
  20. package/dist/cjs/wallet/wallet.js +80 -19
  21. package/dist/cjs/worker/messageBus.js +200 -56
  22. package/dist/esm/contracts/contractWatcher.js +7 -1
  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 +46 -35
  34. package/dist/esm/providers/electrum.js +658 -0
  35. package/dist/esm/providers/indexer.js +6 -2
  36. package/dist/esm/providers/utils.js +3 -0
  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 +133 -17
  41. package/dist/esm/wallet/wallet.js +80 -19
  42. package/dist/esm/worker/messageBus.js +201 -57
  43. package/dist/types/contracts/handlers/default.d.ts +1 -1
  44. package/dist/types/contracts/handlers/helpers.d.ts +1 -1
  45. package/dist/types/contracts/types.d.ts +11 -3
  46. package/dist/types/identity/descriptor.d.ts +35 -0
  47. package/dist/types/identity/descriptorProvider.d.ts +28 -0
  48. package/dist/types/identity/index.d.ts +7 -1
  49. package/dist/types/identity/seedIdentity.d.ts +41 -4
  50. package/dist/types/identity/serialize.d.ts +84 -0
  51. package/dist/types/identity/staticDescriptorProvider.d.ts +18 -0
  52. package/dist/types/index.d.ts +4 -2
  53. package/dist/types/providers/electrum.d.ts +212 -0
  54. package/dist/types/providers/utils.d.ts +1 -0
  55. package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +11 -2
  56. package/dist/types/wallet/serviceWorker/wallet.d.ts +27 -10
  57. package/dist/types/wallet/vtxo-manager.d.ts +15 -0
  58. package/dist/types/wallet/wallet.d.ts +3 -0
  59. package/dist/types/worker/messageBus.d.ts +68 -8
  60. package/package.json +3 -2
@@ -63,6 +63,12 @@ export class ReadonlyWallet {
63
63
  this.walletRepository = walletRepository;
64
64
  this.contractRepository = contractRepository;
65
65
  this.delegatorProvider = delegatorProvider;
66
+ // Outpoints ("txid:vout") committed to an in-flight settle/send. Filtered
67
+ // from getVtxos() so concurrent callers (UI, VtxoManager auto-renewal,
68
+ // another send/settle racing the _txLock) can't reselect coins that are
69
+ // already on their way out. The set is in-memory only: a process crash
70
+ // clears it, and a stale entry only hides a VTXO (never spends one).
71
+ this._pendingSpendOutpoints = new Set();
66
72
  // Guard: detect identity/server network mismatch for descriptor-based identities.
67
73
  // This duplicates the check in setupWalletConfig() so that subclasses
68
74
  // bypassing the factory still get the safety net.
@@ -303,6 +309,9 @@ export class ReadonlyWallet {
303
309
  return vtxos
304
310
  .flatMap((_) => _.vtxos)
305
311
  .filter((vtxo) => {
312
+ if (this._pendingSpendOutpoints.has(`${vtxo.txid}:${vtxo.vout}`)) {
313
+ return false;
314
+ }
306
315
  if (isSpendable(vtxo)) {
307
316
  if (!f.withRecoverable &&
308
317
  (isRecoverable(vtxo) || isExpired(vtxo))) {
@@ -754,6 +763,20 @@ export class ReadonlyWallet {
754
763
  * ```
755
764
  */
756
765
  export class Wallet extends ReadonlyWallet {
766
+ _addPendingSpends(inputs) {
767
+ for (const input of inputs) {
768
+ if ("virtualStatus" in input) {
769
+ this._pendingSpendOutpoints.add(`${input.txid}:${input.vout}`);
770
+ }
771
+ }
772
+ }
773
+ _removePendingSpends(inputs) {
774
+ for (const input of inputs) {
775
+ if ("virtualStatus" in input) {
776
+ this._pendingSpendOutpoints.delete(`${input.txid}:${input.vout}`);
777
+ }
778
+ }
779
+ }
757
780
  _withTxLock(fn) {
758
781
  let release;
759
782
  const lock = new Promise((r) => (release = r));
@@ -969,9 +992,15 @@ export class Wallet extends ReadonlyWallet {
969
992
  amount: BigInt(selected.changeAmount),
970
993
  });
971
994
  }
972
- const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
973
- await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
974
- return arkTxid;
995
+ this._addPendingSpends(selected.inputs);
996
+ try {
997
+ const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
998
+ await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
999
+ return arkTxid;
1000
+ }
1001
+ finally {
1002
+ this._removePendingSpends(selected.inputs);
1003
+ }
975
1004
  });
976
1005
  }
977
1006
  return this.send({
@@ -1017,7 +1046,8 @@ export class Wallet extends ReadonlyWallet {
1017
1046
  const tip = await this.onchainProvider.getChainTip();
1018
1047
  chainTipHeight = tip.height;
1019
1048
  }
1020
- const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => !hasBoardingTxExpired(utxo, boardingTimelock, chainTipHeight));
1049
+ const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => utxo.status.confirmed &&
1050
+ !hasBoardingTxExpired(utxo, boardingTimelock, chainTipHeight));
1021
1051
  const filteredBoardingUtxos = [];
1022
1052
  for (const utxo of boardingUtxos) {
1023
1053
  const inputFee = estimator.evalOnchainInput({
@@ -1041,10 +1071,10 @@ export class Wallet extends ReadonlyWallet {
1041
1071
  weight: 0,
1042
1072
  birth: vtxo.createdAt,
1043
1073
  expiry: vtxo.virtualStatus.batchExpiry
1044
- ? new Date(vtxo.virtualStatus.batchExpiry * 1000)
1045
- : new Date(),
1074
+ ? new Date(vtxo.virtualStatus.batchExpiry)
1075
+ : undefined,
1046
1076
  });
1047
- if (inputFee.value >= vtxo.value) {
1077
+ if (inputFee.satoshis >= vtxo.value) {
1048
1078
  // skip if fees are greater than the virtual output value
1049
1079
  continue;
1050
1080
  }
@@ -1152,11 +1182,29 @@ export class Wallet extends ReadonlyWallet {
1152
1182
  ...params.inputs.map((input) => `${input.txid}:${input.vout}`),
1153
1183
  ];
1154
1184
  const abortController = new AbortController();
1185
+ let stream;
1186
+ // Optimistically hide these inputs from concurrent getVtxos() callers
1187
+ // while the settlement is in flight. Set before safeRegisterIntent so
1188
+ // there's no window between intent registration and coin-visibility.
1189
+ this._addPendingSpends(params.inputs);
1155
1190
  try {
1156
- const stream = this.arkProvider.getEventStream(abortController.signal, topics);
1191
+ stream = this.arkProvider.getEventStream(abortController.signal, topics);
1192
+ // Prime the iterator so the provider opens the SSE subscription
1193
+ // before safeRegisterIntent can trigger server-side batch events.
1194
+ const firstNext = stream.next();
1195
+ // If settle exits before Batch.join consumes the primed result,
1196
+ // keep the orphaned promise from surfacing as an unhandled rejection.
1197
+ void firstNext.catch(() => { });
1198
+ const primedStream = (async function* () {
1199
+ const first = await firstNext;
1200
+ if (!first.done) {
1201
+ yield first.value;
1202
+ }
1203
+ yield* stream;
1204
+ })();
1157
1205
  const intentId = await this.safeRegisterIntent(intent, params.inputs);
1158
1206
  const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
1159
- const commitmentTxid = await Batch.join(stream, handler, {
1207
+ const commitmentTxid = await Batch.join(primedStream, handler, {
1160
1208
  abortController,
1161
1209
  skipVtxoTreeSigning: !hasOffchainOutputs,
1162
1210
  eventCallback: eventCallback
@@ -1180,23 +1228,28 @@ export class Wallet extends ReadonlyWallet {
1180
1228
  throw error;
1181
1229
  }
1182
1230
  finally {
1183
- // close the stream
1231
+ // Clear state first so a synchronous handler firing from abort()
1232
+ // never observes a stale pending-spend set.
1233
+ this._removePendingSpends(params.inputs);
1234
+ // close the stream — abort() fires the in-body handler if the
1235
+ // generator has started iterating; return() also releases the
1236
+ // eager resource if the body is still suspended or never ran
1237
+ // (e.g. safeRegisterIntent threw before Batch.join was called).
1184
1238
  abortController.abort();
1239
+ await stream?.return?.().catch(() => { });
1185
1240
  }
1186
1241
  }
1187
1242
  async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
1188
1243
  // the signed forfeits transactions to submit
1189
1244
  const signedForfeits = [];
1190
- const vtxos = await this.getVtxos();
1245
+ const isVtxo = (input) => "virtualStatus" in input;
1191
1246
  let settlementPsbt = Transaction.fromPSBT(base64.decode(event.commitmentTx));
1192
1247
  let hasBoardingUtxos = false;
1193
1248
  let connectorIndex = 0;
1194
1249
  const connectorsLeaves = connectorsGraph?.leaves() || [];
1195
1250
  for (const input of inputs) {
1196
- // check if the input is an offchain "virtual" coin
1197
- const vtxo = vtxos.find((vtxo) => vtxo.txid === input.txid && vtxo.vout === input.vout);
1198
1251
  // boarding input, we need to sign the settlement tx
1199
- if (!vtxo) {
1252
+ if (!isVtxo(input)) {
1200
1253
  for (let i = 0; i < settlementPsbt.inputsLength; i++) {
1201
1254
  const settlementInput = settlementPsbt.getInput(i);
1202
1255
  if (!settlementInput.txid ||
@@ -1220,7 +1273,7 @@ export class Wallet extends ReadonlyWallet {
1220
1273
  }
1221
1274
  continue;
1222
1275
  }
1223
- if (isRecoverable(vtxo) || isSubdust(vtxo, this.dustAmount)) {
1276
+ if (isRecoverable(input) || isSubdust(input, this.dustAmount)) {
1224
1277
  // recoverable or subdust coin, we don't need to create a forfeit tx
1225
1278
  continue;
1226
1279
  }
@@ -1247,7 +1300,7 @@ export class Wallet extends ReadonlyWallet {
1247
1300
  txid: input.txid,
1248
1301
  index: input.vout,
1249
1302
  witnessUtxo: {
1250
- amount: BigInt(vtxo.value),
1303
+ amount: BigInt(input.value),
1251
1304
  script: VtxoScript.decode(input.tapTree).pkScript,
1252
1305
  },
1253
1306
  sighashType: SigHash.DEFAULT,
@@ -1676,9 +1729,17 @@ export class Wallet extends ReadonlyWallet {
1676
1729
  outputs.push(Extension.create([assetPacket]).txOut());
1677
1730
  }
1678
1731
  const sentAmount = recipients.reduce((sum, r) => sum + r.amount, 0);
1679
- const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
1680
- await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
1681
- return arkTxid;
1732
+ // Optimistically hide selected coins from concurrent getVtxos() while
1733
+ // the offchain tx is in flight.
1734
+ this._addPendingSpends(selectedCoins);
1735
+ try {
1736
+ const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
1737
+ await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
1738
+ return arkTxid;
1739
+ }
1740
+ finally {
1741
+ this._removePendingSpends(selectedCoins);
1742
+ }
1682
1743
  }
1683
1744
  /**
1684
1745
  * Build an offchain transaction from the given inputs and outputs,
@@ -2,15 +2,22 @@
2
2
  import { getActiveServiceWorker, setupServiceWorkerOnce, } from './browser/service-worker-manager.js';
3
3
  import { RestArkProvider } from '../providers/ark.js';
4
4
  import { RestDelegatorProvider } from '../providers/delegator.js';
5
- import { ReadonlySingleKey, SingleKey } from '../identity/index.js';
5
+ import { hydrateIdentity, isSigningSerialized, normalizeSerializedIdentity, } from '../identity/index.js';
6
6
  import { ReadonlyWallet, Wallet } from '../wallet/wallet.js';
7
- import { hex } from "@scure/base";
8
7
  import { MessageBusNotInitializedError, ServiceWorkerTimeoutError, } from './errors.js';
8
+ /**
9
+ * Grace period after a handler times out during which late handler
10
+ * completion is still delivered to the client. Once this expires,
11
+ * the bus sends an "Operation abandoned" error so the message id
12
+ * never goes silent indefinitely.
13
+ */
14
+ const LATE_DELIVERY_GRACE_MS = 5 * 60000;
9
15
  export class MessageBus {
10
16
  /** Create the service-worker message bus with repositories and handler configuration. */
11
- constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, messageTimeoutMs = 30000, debug = false, buildServices, }) {
17
+ constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, messageTimeoutMs = 30000, messageTimeoutOverrides = {}, debug = false, buildServices, }) {
12
18
  this.walletRepository = walletRepository;
13
19
  this.contractRepository = contractRepository;
20
+ this.lateDeliveries = new Set();
14
21
  this.running = false;
15
22
  this.tickTimeout = null;
16
23
  this.tickInProgress = false;
@@ -20,6 +27,8 @@ export class MessageBus {
20
27
  this.handlers = new Map(messageHandlers.map((u) => [u.messageTag, u]));
21
28
  this.tickIntervalMs = tickIntervalMs;
22
29
  this.messageTimeoutMs = messageTimeoutMs;
30
+ this.constructorTimeoutOverrides = { ...messageTimeoutOverrides };
31
+ this.messageTimeoutOverrides = { ...this.constructorTimeoutOverrides };
23
32
  this.debug = debug;
24
33
  this.buildServicesFn = buildServices ?? this.buildServices.bind(this);
25
34
  }
@@ -55,6 +64,11 @@ export class MessageBus {
55
64
  self.clearTimeout(this.tickTimeout);
56
65
  this.tickTimeout = null;
57
66
  }
67
+ for (const record of this.lateDeliveries) {
68
+ record.settled = true;
69
+ self.clearTimeout(record.deadline);
70
+ }
71
+ this.lateDeliveries.clear();
58
72
  self.removeEventListener("message", this.boundOnMessage);
59
73
  await Promise.all(Array.from(this.handlers.values()).map((updater) => updater.stop()));
60
74
  }
@@ -81,7 +95,8 @@ export class MessageBus {
81
95
  const now = Date.now();
82
96
  for (const updater of this.handlers.values()) {
83
97
  try {
84
- const response = await this.withTimeout(updater.tick(now), `${updater.messageTag}:tick`);
98
+ const tickLabel = `${updater.messageTag}:tick`;
99
+ const response = await this.withTimeout(updater.tick(now), this.resolveTimeoutMs(tickLabel, updater.messageTag), tickLabel);
85
100
  if (this.debug)
86
101
  console.log(`[${updater.messageTag}] outgoing tick response:`, response);
87
102
  if (response && response.length > 0) {
@@ -124,6 +139,12 @@ export class MessageBus {
124
139
  this.initialized = false;
125
140
  await Promise.all(Array.from(this.handlers.values()).map((h) => h.stop().catch(() => { })));
126
141
  }
142
+ // Recompute the active timeout map from scratch so a prior init's
143
+ // keys cannot linger after re-init with a smaller map.
144
+ this.messageTimeoutOverrides = {
145
+ ...this.constructorTimeoutOverrides,
146
+ ...(config.messageTimeouts ?? {}),
147
+ };
127
148
  const services = await this.buildServicesFn(config);
128
149
  // Start all handlers
129
150
  for (const updater of this.handlers.values()) {
@@ -146,8 +167,9 @@ export class MessageBus {
146
167
  const delegatorProvider = config.delegatorUrl
147
168
  ? new RestDelegatorProvider(config.delegatorUrl)
148
169
  : undefined;
149
- if ("privateKey" in config.wallet) {
150
- const identity = SingleKey.fromHex(config.wallet.privateKey);
170
+ const serialized = normalizeSerializedIdentity(config.wallet);
171
+ if (isSigningSerialized(serialized)) {
172
+ const identity = hydrateIdentity(serialized);
151
173
  const wallet = await Wallet.create({
152
174
  identity,
153
175
  arkServerUrl: config.arkServer.url,
@@ -161,23 +183,18 @@ export class MessageBus {
161
183
  });
162
184
  return { wallet, arkProvider, readonlyWallet: wallet };
163
185
  }
164
- else if ("publicKey" in config.wallet) {
165
- const identity = ReadonlySingleKey.fromPublicKey(hex.decode(config.wallet.publicKey));
166
- const readonlyWallet = await ReadonlyWallet.create({
167
- identity,
168
- arkServerUrl: config.arkServer.url,
169
- arkServerPublicKey: config.arkServer.publicKey,
170
- indexerUrl: config.indexerUrl,
171
- esploraUrl: config.esploraUrl,
172
- storage,
173
- delegatorProvider,
174
- watcherConfig: config.watcherConfig,
175
- });
176
- return { readonlyWallet, arkProvider };
177
- }
178
- else {
179
- throw new Error("Missing privateKey or publicKey in configuration object");
180
- }
186
+ const identity = hydrateIdentity(serialized);
187
+ const readonlyWallet = await ReadonlyWallet.create({
188
+ identity,
189
+ arkServerUrl: config.arkServer.url,
190
+ arkServerPublicKey: config.arkServer.publicKey,
191
+ indexerUrl: config.indexerUrl,
192
+ esploraUrl: config.esploraUrl,
193
+ storage,
194
+ delegatorProvider,
195
+ watcherConfig: config.watcherConfig,
196
+ });
197
+ return { readonlyWallet, arkProvider };
181
198
  }
182
199
  onMessage(event) {
183
200
  // Keep the service worker alive while async work is pending.
@@ -192,7 +209,7 @@ export class MessageBus {
192
209
  async processMessage(event) {
193
210
  const { id, tag, broadcast } = event.data;
194
211
  if (tag === "PING") {
195
- event.source?.postMessage({ id, tag: "PONG" });
212
+ this.deliverResponse(event.source, { id, tag: "PONG" }, { id, tag: "PONG" });
196
213
  return;
197
214
  }
198
215
  if (tag === "INITIALIZE_MESSAGE_BUS") {
@@ -203,7 +220,7 @@ export class MessageBus {
203
220
  // performs network calls (buildServices) and handler startup
204
221
  // that may legitimately exceed the message timeout.
205
222
  await this.waitForInit(event.data.config);
206
- event.source?.postMessage({ id, tag });
223
+ this.deliverResponse(event.source, { id, tag }, { id, tag });
207
224
  if (this.debug) {
208
225
  console.log("MessageBus initialized");
209
226
  }
@@ -216,45 +233,60 @@ export class MessageBus {
216
233
  // hanging forever. This happens when the browser kills and restarts
217
234
  // the service worker — the new instance has initialized=false and
218
235
  // messages arrive before INITIALIZE_MESSAGE_BUS is re-sent.
219
- event.source?.postMessage({
236
+ const fallbackTag = tag ?? "unknown";
237
+ this.deliverResponse(event.source, {
220
238
  id,
221
- tag: tag ?? "unknown",
239
+ tag: fallbackTag,
222
240
  error: new MessageBusNotInitializedError(),
223
- });
241
+ }, { id, tag: fallbackTag });
224
242
  return;
225
243
  }
226
244
  if (!id || !tag) {
227
245
  if (this.debug)
228
246
  console.error("Invalid message received, missing required fields:", event.data);
229
- event.source?.postMessage({
247
+ const fallbackTag = tag ?? "unknown";
248
+ this.deliverResponse(event.source, {
230
249
  id,
231
- tag: tag ?? "unknown",
250
+ tag: fallbackTag,
232
251
  error: new TypeError("Invalid message received, missing required fields"),
233
- });
252
+ }, { id, tag: fallbackTag });
234
253
  return;
235
254
  }
255
+ const messageType = this.extractMessageType(event.data);
236
256
  if (broadcast) {
237
257
  const updaters = Array.from(this.handlers.values());
238
- const results = await Promise.allSettled(updaters.map((updater) => this.withTimeout(updater.handleMessage(event.data), updater.messageTag)));
258
+ const entries = updaters.map((updater) => {
259
+ const label = this.labelFor(messageType, updater.messageTag);
260
+ const timeoutMs = this.resolveTimeoutMs(messageType, updater.messageTag);
261
+ const handlerPromise = updater.handleMessage(event.data);
262
+ const raced = updater.isLongRunning?.(event.data)
263
+ ? handlerPromise
264
+ : this.withTimeout(handlerPromise, timeoutMs, label);
265
+ return { updater, handlerPromise, raced };
266
+ });
267
+ const results = await Promise.allSettled(entries.map((e) => e.raced));
239
268
  results.forEach((result, index) => {
240
- const updater = updaters[index];
269
+ const { updater, handlerPromise } = entries[index];
270
+ const handlerTag = updater.messageTag;
271
+ const context = { id, tag: handlerTag, messageType };
241
272
  if (result.status === "fulfilled") {
242
273
  const response = result.value;
243
- if (response) {
244
- event.source?.postMessage(response);
245
- }
274
+ // Always deliver a response so the caller's message id
275
+ // never goes silent. Handlers returning null/undefined
276
+ // get an explicit ack envelope.
277
+ this.deliverResponse(event.source, response ?? { id, tag: handlerTag }, context);
246
278
  }
247
279
  else {
248
280
  if (this.debug)
249
- console.error(`[${updater.messageTag}] handleMessage failed`, result.reason);
250
- const error = result.reason instanceof Error
251
- ? result.reason
252
- : new Error(String(result.reason));
253
- event.source?.postMessage({
254
- id,
255
- tag: updater.messageTag,
256
- error,
257
- });
281
+ console.error(`[${handlerTag}] handleMessage failed`, result.reason);
282
+ const error = toError(result.reason);
283
+ this.deliverResponse(event.source, { id, tag: handlerTag, error }, context);
284
+ // If the error was a timeout, keep watching the
285
+ // underlying handler and surface its eventual result
286
+ // under the same id.
287
+ if (result.reason instanceof ServiceWorkerTimeoutError) {
288
+ this.attachLateDelivery(handlerPromise, event.source, id, handlerTag, messageType);
289
+ }
258
290
  }
259
291
  });
260
292
  return;
@@ -263,35 +295,53 @@ export class MessageBus {
263
295
  if (!updater) {
264
296
  if (this.debug)
265
297
  console.warn(`[${tag}] unknown message tag, ignoring message`);
298
+ this.deliverResponse(event.source, {
299
+ id,
300
+ tag,
301
+ error: new Error(`Unknown handler tag: ${tag}`),
302
+ }, { id, tag, messageType });
266
303
  return;
267
304
  }
305
+ const label = this.labelFor(messageType, tag);
306
+ const timeoutMs = this.resolveTimeoutMs(messageType, tag);
307
+ const handlerPromise = updater.handleMessage(event.data);
308
+ const context = { id, tag, messageType };
268
309
  try {
269
- const response = await this.withTimeout(updater.handleMessage(event.data), tag);
310
+ const response = updater.isLongRunning?.(event.data)
311
+ ? await handlerPromise
312
+ : await this.withTimeout(handlerPromise, timeoutMs, label);
270
313
  if (this.debug)
271
314
  console.log(`[${tag}] outgoing response:`, response);
272
- if (response) {
273
- event.source?.postMessage(response);
274
- }
315
+ // Always deliver a response so the caller's message id never
316
+ // goes silent. A handler returning null/undefined yields an
317
+ // explicit ack envelope.
318
+ this.deliverResponse(event.source, response ?? { id, tag }, context);
275
319
  }
276
320
  catch (err) {
277
321
  if (this.debug)
278
322
  console.error(`[${tag}] handleMessage failed`, err);
279
- const error = err instanceof Error ? err : new Error(String(err));
280
- event.source?.postMessage({ id, tag, error });
323
+ const error = toError(err);
324
+ this.deliverResponse(event.source, { id, tag, error }, context);
325
+ // When we abandoned the handler via timeout, keep watching it
326
+ // so the client's message id eventually gets a final response.
327
+ if (err instanceof ServiceWorkerTimeoutError) {
328
+ this.attachLateDelivery(handlerPromise, event.source, id, tag, messageType);
329
+ }
281
330
  }
282
331
  }
283
332
  /**
284
333
  * Race `promise` against a timeout. Note: this does NOT cancel the
285
- * underlying work — the original promise keeps running. This is safe
286
- * here because only the caller (not the handler) posts the response.
334
+ * underlying work — the original promise keeps running. Call
335
+ * `attachLateDelivery` after catching the timeout to surface the
336
+ * eventual result so the message id does not go silent.
287
337
  */
288
- withTimeout(promise, label) {
289
- if (this.messageTimeoutMs <= 0)
338
+ withTimeout(promise, timeoutMs, label) {
339
+ if (timeoutMs <= 0)
290
340
  return promise;
291
341
  return new Promise((resolve, reject) => {
292
342
  const timer = self.setTimeout(() => {
293
- reject(new ServiceWorkerTimeoutError(`Message handler timed out after ${this.messageTimeoutMs}ms (${label})`));
294
- }, this.messageTimeoutMs);
343
+ reject(new ServiceWorkerTimeoutError(`Message handler timed out after ${timeoutMs}ms (${label})`));
344
+ }, timeoutMs);
295
345
  promise.then((val) => {
296
346
  self.clearTimeout(timer);
297
347
  resolve(val);
@@ -301,6 +351,97 @@ export class MessageBus {
301
351
  });
302
352
  });
303
353
  }
354
+ /**
355
+ * Extract the declared `type` from a request envelope (e.g. "SETTLE").
356
+ * Not every envelope carries a type (PING/INIT are special cased
357
+ * earlier), so this returns undefined for envelopes that lack one.
358
+ */
359
+ extractMessageType(data) {
360
+ const maybeType = data.type;
361
+ return typeof maybeType === "string" ? maybeType : undefined;
362
+ }
363
+ /**
364
+ * Resolve the timeout for an operation. Message-type overrides take
365
+ * precedence over handler-tag overrides, with the bus-wide default
366
+ * (`messageTimeoutMs`) as the final fallback.
367
+ */
368
+ resolveTimeoutMs(messageType, handlerTag) {
369
+ if (messageType &&
370
+ Object.prototype.hasOwnProperty.call(this.messageTimeoutOverrides, messageType)) {
371
+ return this.messageTimeoutOverrides[messageType];
372
+ }
373
+ if (Object.prototype.hasOwnProperty.call(this.messageTimeoutOverrides, handlerTag)) {
374
+ return this.messageTimeoutOverrides[handlerTag];
375
+ }
376
+ return this.messageTimeoutMs;
377
+ }
378
+ /**
379
+ * Build a human-readable label for timeout errors. Format:
380
+ * `"<MESSAGE_TYPE> via <HANDLER_TAG>"` when both are known, else the
381
+ * handler tag alone. Used so timeout errors name the operation the
382
+ * client actually triggered (e.g. SETTLE) rather than just the
383
+ * handler that received it (e.g. WALLET_UPDATER).
384
+ */
385
+ labelFor(messageType, handlerTag) {
386
+ return messageType ? `${messageType} via ${handlerTag}` : handlerTag;
387
+ }
388
+ /**
389
+ * Post a response to the originating client. When `source` is null
390
+ * (client tab closed, detached frame, etc.) the response cannot be
391
+ * delivered; we log the drop in debug mode so it is not invisible.
392
+ */
393
+ deliverResponse(source, response, context) {
394
+ if (!source) {
395
+ if (this.debug)
396
+ console.warn(`[${context.tag}] cannot deliver response: event.source is null`, {
397
+ id: context.id,
398
+ messageType: context.messageType,
399
+ });
400
+ return;
401
+ }
402
+ source.postMessage(response);
403
+ }
404
+ /**
405
+ * After a handler times out the client has already received a timeout
406
+ * error, but the handler keeps running. Attach a follow-up so the
407
+ * handler's eventual result (or error) is delivered under the same
408
+ * message id, or — if the handler never completes within
409
+ * {@link LATE_DELIVERY_GRACE_MS} — an "Operation abandoned" error is
410
+ * sent so the client's listener (if still attached) does not hang.
411
+ */
412
+ attachLateDelivery(handlerPromise, source, id, tag, messageType) {
413
+ const context = { id, tag, messageType };
414
+ const record = {
415
+ settled: false,
416
+ deadline: self.setTimeout(() => {
417
+ if (record.settled)
418
+ return;
419
+ record.settled = true;
420
+ this.lateDeliveries.delete(record);
421
+ this.deliverResponse(source, {
422
+ id,
423
+ tag,
424
+ error: new Error(`Operation abandoned: handler did not complete within ${LATE_DELIVERY_GRACE_MS}ms after timeout (${this.labelFor(messageType, tag)})`),
425
+ }, context);
426
+ }, LATE_DELIVERY_GRACE_MS),
427
+ };
428
+ this.lateDeliveries.add(record);
429
+ handlerPromise.then((response) => {
430
+ if (record.settled)
431
+ return;
432
+ record.settled = true;
433
+ self.clearTimeout(record.deadline);
434
+ this.lateDeliveries.delete(record);
435
+ this.deliverResponse(source, response ?? { id, tag }, context);
436
+ }, (err) => {
437
+ if (record.settled)
438
+ return;
439
+ record.settled = true;
440
+ self.clearTimeout(record.deadline);
441
+ this.lateDeliveries.delete(record);
442
+ this.deliverResponse(source, { id, tag, error: toError(err) }, context);
443
+ });
444
+ }
304
445
  /**
305
446
  * Returns the registered SW for the path.
306
447
  * It uses the functions in `service-worker-manager.ts` module.
@@ -323,3 +464,6 @@ export class MessageBus {
323
464
  return getActiveServiceWorker(path);
324
465
  }
325
466
  }
467
+ function toError(value) {
468
+ return value instanceof Error ? value : new Error(String(value));
469
+ }
@@ -10,7 +10,7 @@ export interface DefaultContractParams {
10
10
  csvTimelock: RelativeTimelock;
11
11
  }
12
12
  /**
13
- * Handler for default wallet virtual outputs.
13
+ * Handler for default wallet VTXOs.
14
14
  *
15
15
  * Default contracts use the standard forfeit + exit tapscript:
16
16
  * - forfeit: (Alice + Server) multisig for collaborative spending
@@ -9,7 +9,7 @@ export declare function timelockToSequence(timelock: RelativeTimelock): number;
9
9
  */
10
10
  export declare function sequenceToTimelock(sequence: number): RelativeTimelock;
11
11
  /**
12
- * Resolve wallet's role from explicit role or by matching pubkey.
12
+ * Resolve wallet's role from explicit role or by matching descriptor/pubkey.
13
13
  */
14
14
  export declare function resolveRole(contract: Contract, context: PathContext): "sender" | "receiver" | undefined;
15
15
  /**
@@ -96,13 +96,21 @@ export interface PathContext {
96
96
  /** Current block height, when known. */
97
97
  blockHeight?: number;
98
98
  /**
99
- * Wallet public key encoded as 32-byte x-only hex.
100
- * Used by handlers to determine the wallet's role in multi-party contracts.
99
+ * Wallet's descriptor for signing.
100
+ * Format: tr(pubkey) for static keys, tr([fingerprint/path']xpub/0/{index}) for HD.
101
+ * Used by handlers to determine wallet's role in multi-party contracts.
102
+ */
103
+ walletDescriptor?: string;
104
+ /**
105
+ * Wallet's public key (x-only, 32 bytes hex).
106
+ * @deprecated Use walletDescriptor instead.
101
107
  */
102
108
  walletPubKey?: string;
103
109
  /**
104
110
  * Explicit role override for multi-party contracts such as VHTLC.
105
- * If not provided, the handler may derive the role from `walletPubKey`.
111
+ * If not provided, the handler may derive the role by matching
112
+ * {@link walletDescriptor} (preferred) — or {@link walletPubKey} as a
113
+ * fallback — against the contract's sender/receiver params.
106
114
  */
107
115
  role?: string;
108
116
  /** The specific virtual output being evaluated. */
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Check if a string is a descriptor of the shape `tr(...)`.
3
+ *
4
+ * This is a shape check only — it does not validate the inner key material.
5
+ * Use {@link expand} (via {@link extractPubKey} / {@link parseHDDescriptor})
6
+ * for full parsing. The guard rejects empty bodies and missing/trailing
7
+ * parentheses so callers can safely branch on descriptor vs. raw pubkey.
8
+ */
9
+ export declare function isDescriptor(value: string): boolean;
10
+ /**
11
+ * Normalize a value to descriptor format.
12
+ * If already a descriptor, return as-is. If hex pubkey, wrap as tr(pubkey).
13
+ * Throws when the value is empty or not a string so we never produce
14
+ * malformed descriptors like `tr()` that downstream parsers would reject.
15
+ */
16
+ export declare function normalizeToDescriptor(value: string): string;
17
+ /**
18
+ * Extract the public key from a simple descriptor.
19
+ * For simple descriptors (tr(pubkey)), extracts the pubkey using the library.
20
+ * For HD descriptors, throws — use DescriptorProvider to derive the key.
21
+ */
22
+ export declare function extractPubKey(descriptor: string): string;
23
+ /** Parsed HD descriptor components. */
24
+ export interface ParsedHDDescriptor {
25
+ fingerprint: string;
26
+ basePath: string;
27
+ xpub: string;
28
+ derivationPath: string;
29
+ }
30
+ /**
31
+ * Parse an HD descriptor into its components.
32
+ * HD descriptors have the format: tr([fingerprint/path']xpub/derivation)
33
+ * Returns null if the descriptor is not in HD format.
34
+ */
35
+ export declare function parseHDDescriptor(descriptor: string): ParsedHDDescriptor | null;