@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
@@ -68,6 +68,12 @@ class ReadonlyWallet {
68
68
  this.walletRepository = walletRepository;
69
69
  this.contractRepository = contractRepository;
70
70
  this.delegatorProvider = delegatorProvider;
71
+ // Outpoints ("txid:vout") committed to an in-flight settle/send. Filtered
72
+ // from getVtxos() so concurrent callers (UI, VtxoManager auto-renewal,
73
+ // another send/settle racing the _txLock) can't reselect coins that are
74
+ // already on their way out. The set is in-memory only: a process crash
75
+ // clears it, and a stale entry only hides a VTXO (never spends one).
76
+ this._pendingSpendOutpoints = new Set();
71
77
  // Guard: detect identity/server network mismatch for descriptor-based identities.
72
78
  // This duplicates the check in setupWalletConfig() so that subclasses
73
79
  // bypassing the factory still get the safety net.
@@ -308,6 +314,9 @@ class ReadonlyWallet {
308
314
  return vtxos
309
315
  .flatMap((_) => _.vtxos)
310
316
  .filter((vtxo) => {
317
+ if (this._pendingSpendOutpoints.has(`${vtxo.txid}:${vtxo.vout}`)) {
318
+ return false;
319
+ }
311
320
  if ((0, _1.isSpendable)(vtxo)) {
312
321
  if (!f.withRecoverable &&
313
322
  ((0, _1.isRecoverable)(vtxo) || (0, _1.isExpired)(vtxo))) {
@@ -760,6 +769,20 @@ exports.ReadonlyWallet = ReadonlyWallet;
760
769
  * ```
761
770
  */
762
771
  class Wallet extends ReadonlyWallet {
772
+ _addPendingSpends(inputs) {
773
+ for (const input of inputs) {
774
+ if ("virtualStatus" in input) {
775
+ this._pendingSpendOutpoints.add(`${input.txid}:${input.vout}`);
776
+ }
777
+ }
778
+ }
779
+ _removePendingSpends(inputs) {
780
+ for (const input of inputs) {
781
+ if ("virtualStatus" in input) {
782
+ this._pendingSpendOutpoints.delete(`${input.txid}:${input.vout}`);
783
+ }
784
+ }
785
+ }
763
786
  _withTxLock(fn) {
764
787
  let release;
765
788
  const lock = new Promise((r) => (release = r));
@@ -975,9 +998,15 @@ class Wallet extends ReadonlyWallet {
975
998
  amount: BigInt(selected.changeAmount),
976
999
  });
977
1000
  }
978
- const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
979
- await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
980
- return arkTxid;
1001
+ this._addPendingSpends(selected.inputs);
1002
+ try {
1003
+ const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
1004
+ await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
1005
+ return arkTxid;
1006
+ }
1007
+ finally {
1008
+ this._removePendingSpends(selected.inputs);
1009
+ }
981
1010
  });
982
1011
  }
983
1012
  return this.send({
@@ -1023,7 +1052,8 @@ class Wallet extends ReadonlyWallet {
1023
1052
  const tip = await this.onchainProvider.getChainTip();
1024
1053
  chainTipHeight = tip.height;
1025
1054
  }
1026
- const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => !(0, arkTransaction_1.hasBoardingTxExpired)(utxo, boardingTimelock, chainTipHeight));
1055
+ const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => utxo.status.confirmed &&
1056
+ !(0, arkTransaction_1.hasBoardingTxExpired)(utxo, boardingTimelock, chainTipHeight));
1027
1057
  const filteredBoardingUtxos = [];
1028
1058
  for (const utxo of boardingUtxos) {
1029
1059
  const inputFee = estimator.evalOnchainInput({
@@ -1047,10 +1077,10 @@ class Wallet extends ReadonlyWallet {
1047
1077
  weight: 0,
1048
1078
  birth: vtxo.createdAt,
1049
1079
  expiry: vtxo.virtualStatus.batchExpiry
1050
- ? new Date(vtxo.virtualStatus.batchExpiry * 1000)
1051
- : new Date(),
1080
+ ? new Date(vtxo.virtualStatus.batchExpiry)
1081
+ : undefined,
1052
1082
  });
1053
- if (inputFee.value >= vtxo.value) {
1083
+ if (inputFee.satoshis >= vtxo.value) {
1054
1084
  // skip if fees are greater than the virtual output value
1055
1085
  continue;
1056
1086
  }
@@ -1158,11 +1188,29 @@ class Wallet extends ReadonlyWallet {
1158
1188
  ...params.inputs.map((input) => `${input.txid}:${input.vout}`),
1159
1189
  ];
1160
1190
  const abortController = new AbortController();
1191
+ let stream;
1192
+ // Optimistically hide these inputs from concurrent getVtxos() callers
1193
+ // while the settlement is in flight. Set before safeRegisterIntent so
1194
+ // there's no window between intent registration and coin-visibility.
1195
+ this._addPendingSpends(params.inputs);
1161
1196
  try {
1162
- const stream = this.arkProvider.getEventStream(abortController.signal, topics);
1197
+ stream = this.arkProvider.getEventStream(abortController.signal, topics);
1198
+ // Prime the iterator so the provider opens the SSE subscription
1199
+ // before safeRegisterIntent can trigger server-side batch events.
1200
+ const firstNext = stream.next();
1201
+ // If settle exits before Batch.join consumes the primed result,
1202
+ // keep the orphaned promise from surfacing as an unhandled rejection.
1203
+ void firstNext.catch(() => { });
1204
+ const primedStream = (async function* () {
1205
+ const first = await firstNext;
1206
+ if (!first.done) {
1207
+ yield first.value;
1208
+ }
1209
+ yield* stream;
1210
+ })();
1163
1211
  const intentId = await this.safeRegisterIntent(intent, params.inputs);
1164
1212
  const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
1165
- const commitmentTxid = await batch_1.Batch.join(stream, handler, {
1213
+ const commitmentTxid = await batch_1.Batch.join(primedStream, handler, {
1166
1214
  abortController,
1167
1215
  skipVtxoTreeSigning: !hasOffchainOutputs,
1168
1216
  eventCallback: eventCallback
@@ -1186,23 +1234,28 @@ class Wallet extends ReadonlyWallet {
1186
1234
  throw error;
1187
1235
  }
1188
1236
  finally {
1189
- // close the stream
1237
+ // Clear state first so a synchronous handler firing from abort()
1238
+ // never observes a stale pending-spend set.
1239
+ this._removePendingSpends(params.inputs);
1240
+ // close the stream — abort() fires the in-body handler if the
1241
+ // generator has started iterating; return() also releases the
1242
+ // eager resource if the body is still suspended or never ran
1243
+ // (e.g. safeRegisterIntent threw before Batch.join was called).
1190
1244
  abortController.abort();
1245
+ await stream?.return?.().catch(() => { });
1191
1246
  }
1192
1247
  }
1193
1248
  async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
1194
1249
  // the signed forfeits transactions to submit
1195
1250
  const signedForfeits = [];
1196
- const vtxos = await this.getVtxos();
1251
+ const isVtxo = (input) => "virtualStatus" in input;
1197
1252
  let settlementPsbt = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(event.commitmentTx));
1198
1253
  let hasBoardingUtxos = false;
1199
1254
  let connectorIndex = 0;
1200
1255
  const connectorsLeaves = connectorsGraph?.leaves() || [];
1201
1256
  for (const input of inputs) {
1202
- // check if the input is an offchain "virtual" coin
1203
- const vtxo = vtxos.find((vtxo) => vtxo.txid === input.txid && vtxo.vout === input.vout);
1204
1257
  // boarding input, we need to sign the settlement tx
1205
- if (!vtxo) {
1258
+ if (!isVtxo(input)) {
1206
1259
  for (let i = 0; i < settlementPsbt.inputsLength; i++) {
1207
1260
  const settlementInput = settlementPsbt.getInput(i);
1208
1261
  if (!settlementInput.txid ||
@@ -1226,7 +1279,7 @@ class Wallet extends ReadonlyWallet {
1226
1279
  }
1227
1280
  continue;
1228
1281
  }
1229
- if ((0, _1.isRecoverable)(vtxo) || (0, _1.isSubdust)(vtxo, this.dustAmount)) {
1282
+ if ((0, _1.isRecoverable)(input) || (0, _1.isSubdust)(input, this.dustAmount)) {
1230
1283
  // recoverable or subdust coin, we don't need to create a forfeit tx
1231
1284
  continue;
1232
1285
  }
@@ -1253,7 +1306,7 @@ class Wallet extends ReadonlyWallet {
1253
1306
  txid: input.txid,
1254
1307
  index: input.vout,
1255
1308
  witnessUtxo: {
1256
- amount: BigInt(vtxo.value),
1309
+ amount: BigInt(input.value),
1257
1310
  script: base_2.VtxoScript.decode(input.tapTree).pkScript,
1258
1311
  },
1259
1312
  sighashType: btc_signer_1.SigHash.DEFAULT,
@@ -1682,9 +1735,17 @@ class Wallet extends ReadonlyWallet {
1682
1735
  outputs.push(extension_1.Extension.create([assetPacket]).txOut());
1683
1736
  }
1684
1737
  const sentAmount = recipients.reduce((sum, r) => sum + r.amount, 0);
1685
- const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
1686
- await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
1687
- return arkTxid;
1738
+ // Optimistically hide selected coins from concurrent getVtxos() while
1739
+ // the offchain tx is in flight.
1740
+ this._addPendingSpends(selectedCoins);
1741
+ try {
1742
+ const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
1743
+ await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
1744
+ return arkTxid;
1745
+ }
1746
+ finally {
1747
+ this._removePendingSpends(selectedCoins);
1748
+ }
1688
1749
  }
1689
1750
  /**
1690
1751
  * Build an offchain transaction from the given inputs and outputs,
@@ -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
  *
@@ -267,7 +268,12 @@ export class ContractWatcher {
267
268
  // indefinitely and block the caller.
268
269
  // Error management must be implemented to ensure the connection
269
270
  // is restored and events are fired.
270
- console.error(e);
271
+ if (isEventSourceError(e)) {
272
+ console.debug("ContractWatcher subscription disconnected; reconnecting");
273
+ }
274
+ else {
275
+ console.error(e);
276
+ }
271
277
  this.connectionState = "disconnected";
272
278
  this.eventCallback?.({
273
279
  type: "connection_reset",
@@ -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
  },