@arkade-os/sdk 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DelegatorManagerImpl = void 0;
4
+ exports.findDestinationOutputIndex = findDestinationOutputIndex;
4
5
  const __1 = require("..");
5
6
  const base_1 = require("@scure/base");
6
7
  const base_2 = require("../script/base");
7
8
  const forfeit_1 = require("../forfeit");
8
9
  const btc_signer_1 = require("@scure/btc-signer");
10
+ const utils_js_1 = require("@scure/btc-signer/utils.js");
9
11
  const networks_1 = require("../networks");
10
12
  const asset_1 = require("./asset");
11
13
  const extension_1 = require("../extension");
@@ -173,7 +175,7 @@ async function delegate(identity, delegatorProvider, arkInfoProvider, vtxos, des
173
175
  script: destinationScript,
174
176
  amount: amount,
175
177
  });
176
- const registerIntent = await makeSignedDelegateIntent(identity, vtxos, outputs, [], [pubkey], delegateAtSeconds);
178
+ const registerIntent = await makeSignedDelegateIntent(identity, vtxos, outputs, [], [pubkey], delegateAtSeconds, destinationScript);
177
179
  const forfeitOutputScript = btc_signer_1.OutScript.encode((0, btc_signer_1.Address)((0, networks_1.getNetwork)(network)).decode(forfeitAddress));
178
180
  const forfeits = await Promise.all(vtxos
179
181
  .filter((v) => !(0, __1.isRecoverable)(v))
@@ -218,7 +220,7 @@ async function makeDelegateForfeitTx(input, connectorAmount, delegatePubkey, for
218
220
  });
219
221
  return identity.sign(tx);
220
222
  }
221
- async function makeSignedDelegateIntent(identity, coins, outputs, onchainOutputsIndexes, cosignerPubKeys, validAt) {
223
+ async function makeSignedDelegateIntent(identity, coins, outputs, onchainOutputsIndexes, cosignerPubKeys, validAt, destinationScript) {
222
224
  // if some of the inputs hold assets, build the asset packet and append as output
223
225
  // in the intent proof tx, there is a "fake" input at index 0
224
226
  // so the real coin indices are offset by +1
@@ -232,8 +234,11 @@ async function makeSignedDelegateIntent(identity, coins, outputs, onchainOutputs
232
234
  }
233
235
  }
234
236
  let outputAssets;
235
- let assetOutputIndex; // where to send the assets
237
+ const assetOutputIndex = findDestinationOutputIndex(outputs, destinationScript);
236
238
  if (assetInputs.size > 0) {
239
+ if (assetOutputIndex === -1) {
240
+ throw new Error("Cannot assign assets: no output matches the destination address");
241
+ }
237
242
  // collect all input assets and assign them to the first offchain output
238
243
  const allAssets = new Map();
239
244
  for (const [, assets] of assetInputs) {
@@ -246,11 +251,6 @@ async function makeSignedDelegateIntent(identity, coins, outputs, onchainOutputs
246
251
  for (const [assetId, amount] of allAssets) {
247
252
  outputAssets.push({ assetId, amount: Number(amount) });
248
253
  }
249
- const firstOffchainIndex = outputs.findIndex((_, i) => !onchainOutputsIndexes.includes(i));
250
- if (firstOffchainIndex === -1) {
251
- throw new Error("Cannot settle assets without an offchain output");
252
- }
253
- assetOutputIndex = firstOffchainIndex;
254
254
  }
255
255
  const recipients = outputs.map((output, i) => ({
256
256
  address: "", // not needed for asset packet creation
@@ -275,6 +275,13 @@ async function makeSignedDelegateIntent(identity, coins, outputs, onchainOutputs
275
275
  message,
276
276
  };
277
277
  }
278
+ /**
279
+ * Finds the index of the output whose script matches the destination script.
280
+ * Returns -1 if no match is found.
281
+ */
282
+ function findDestinationOutputIndex(outputs, destinationScript) {
283
+ return outputs.findIndex((o) => o.script && (0, utils_js_1.equalBytes)(o.script, destinationScript));
284
+ }
278
285
  function getDayTimestamp(timestamp) {
279
286
  const date = new Date(timestamp);
280
287
  date.setUTCHours(0, 0, 0, 0);
@@ -9,7 +9,7 @@ const identity_1 = require("../identity");
9
9
  const wallet_1 = require("../wallet/wallet");
10
10
  const base_1 = require("@scure/base");
11
11
  class MessageBus {
12
- constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, debug = false, buildServices, }) {
12
+ constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, messageTimeoutMs = 30000, debug = false, buildServices, }) {
13
13
  this.walletRepository = walletRepository;
14
14
  this.contractRepository = contractRepository;
15
15
  this.running = false;
@@ -17,8 +17,10 @@ class MessageBus {
17
17
  this.tickInProgress = false;
18
18
  this.debug = false;
19
19
  this.initialized = false;
20
+ this.boundOnMessage = this.onMessage.bind(this);
20
21
  this.handlers = new Map(messageHandlers.map((u) => [u.messageTag, u]));
21
22
  this.tickIntervalMs = tickIntervalMs;
23
+ this.messageTimeoutMs = messageTimeoutMs;
22
24
  this.debug = debug;
23
25
  this.buildServicesFn = buildServices ?? this.buildServices.bind(this);
24
26
  }
@@ -29,7 +31,7 @@ class MessageBus {
29
31
  if (this.debug)
30
32
  console.log("MessageBus starting");
31
33
  // Hook message routing
32
- self.addEventListener("message", this.onMessage.bind(this));
34
+ self.addEventListener("message", this.boundOnMessage);
33
35
  // activate service worker immediately
34
36
  self.addEventListener("install", () => {
35
37
  self.skipWaiting();
@@ -52,7 +54,7 @@ class MessageBus {
52
54
  self.clearTimeout(this.tickTimeout);
53
55
  this.tickTimeout = null;
54
56
  }
55
- self.removeEventListener("message", this.onMessage.bind(this));
57
+ self.removeEventListener("message", this.boundOnMessage);
56
58
  await Promise.all(Array.from(this.handlers.values()).map((updater) => updater.stop()));
57
59
  }
58
60
  scheduleNextTick() {
@@ -78,7 +80,7 @@ class MessageBus {
78
80
  const now = Date.now();
79
81
  for (const updater of this.handlers.values()) {
80
82
  try {
81
- const response = await updater.tick(now);
83
+ const response = await this.withTimeout(updater.tick(now), `${updater.messageTag}:tick`);
82
84
  if (this.debug)
83
85
  console.log(`[${updater.messageTag}] outgoing tick response:`, response);
84
86
  if (response && response.length > 0) {
@@ -169,12 +171,25 @@ class MessageBus {
169
171
  throw new Error("Missing privateKey or publicKey in configuration object");
170
172
  }
171
173
  }
172
- async onMessage(event) {
174
+ onMessage(event) {
175
+ // Keep the service worker alive while async work is pending.
176
+ // Without this, the browser may terminate the SW mid-operation,
177
+ // causing all pending responses to be lost silently.
178
+ const promise = this.processMessage(event);
179
+ if (typeof event.waitUntil === "function") {
180
+ event.waitUntil(promise);
181
+ }
182
+ return promise;
183
+ }
184
+ async processMessage(event) {
173
185
  const { id, tag, broadcast } = event.data;
174
186
  if (tag === "INITIALIZE_MESSAGE_BUS") {
175
187
  if (this.debug) {
176
188
  console.log("Init Command received");
177
189
  }
190
+ // Intentionally not wrapped with withTimeout: initialization
191
+ // performs network calls (buildServices) and handler startup
192
+ // that may legitimately exceed the message timeout.
178
193
  await this.waitForInit(event.data.config);
179
194
  event.source?.postMessage({ id, tag });
180
195
  if (this.debug) {
@@ -208,7 +223,7 @@ class MessageBus {
208
223
  }
209
224
  if (broadcast) {
210
225
  const updaters = Array.from(this.handlers.values());
211
- const results = await Promise.allSettled(updaters.map((updater) => updater.handleMessage(event.data)));
226
+ const results = await Promise.allSettled(updaters.map((updater) => this.withTimeout(updater.handleMessage(event.data), updater.messageTag)));
212
227
  results.forEach((result, index) => {
213
228
  const updater = updaters[index];
214
229
  if (result.status === "fulfilled") {
@@ -239,7 +254,7 @@ class MessageBus {
239
254
  return;
240
255
  }
241
256
  try {
242
- const response = await updater.handleMessage(event.data);
257
+ const response = await this.withTimeout(updater.handleMessage(event.data), tag);
243
258
  if (this.debug)
244
259
  console.log(`[${tag}] outgoing response:`, response);
245
260
  if (response) {
@@ -253,6 +268,27 @@ class MessageBus {
253
268
  event.source?.postMessage({ id, tag, error });
254
269
  }
255
270
  }
271
+ /**
272
+ * Race `promise` against a timeout. Note: this does NOT cancel the
273
+ * underlying work — the original promise keeps running. This is safe
274
+ * here because only the caller (not the handler) posts the response.
275
+ */
276
+ withTimeout(promise, label) {
277
+ if (this.messageTimeoutMs <= 0)
278
+ return promise;
279
+ return new Promise((resolve, reject) => {
280
+ const timer = self.setTimeout(() => {
281
+ reject(new Error(`Message handler timed out after ${this.messageTimeoutMs}ms (${label})`));
282
+ }, this.messageTimeoutMs);
283
+ promise.then((val) => {
284
+ self.clearTimeout(timer);
285
+ resolve(val);
286
+ }, (err) => {
287
+ self.clearTimeout(timer);
288
+ reject(err);
289
+ });
290
+ });
291
+ }
256
292
  /**
257
293
  * Returns the registered SW for the path.
258
294
  * It uses the functions in `service-worker-manager.ts` module.
@@ -3,6 +3,7 @@ import { base64, hex } from "@scure/base";
3
3
  import { scriptFromTapLeafScript } from '../script/base.js';
4
4
  import { buildForfeitTxWithOutput } from '../forfeit.js';
5
5
  import { Address, OutScript, SigHash } from "@scure/btc-signer";
6
+ import { equalBytes } from "@scure/btc-signer/utils.js";
6
7
  import { getNetwork } from '../networks.js';
7
8
  import { createAssetPacket } from './asset.js';
8
9
  import { Extension } from '../extension/index.js';
@@ -169,7 +170,7 @@ async function delegate(identity, delegatorProvider, arkInfoProvider, vtxos, des
169
170
  script: destinationScript,
170
171
  amount: amount,
171
172
  });
172
- const registerIntent = await makeSignedDelegateIntent(identity, vtxos, outputs, [], [pubkey], delegateAtSeconds);
173
+ const registerIntent = await makeSignedDelegateIntent(identity, vtxos, outputs, [], [pubkey], delegateAtSeconds, destinationScript);
173
174
  const forfeitOutputScript = OutScript.encode(Address(getNetwork(network)).decode(forfeitAddress));
174
175
  const forfeits = await Promise.all(vtxos
175
176
  .filter((v) => !isRecoverable(v))
@@ -214,7 +215,7 @@ async function makeDelegateForfeitTx(input, connectorAmount, delegatePubkey, for
214
215
  });
215
216
  return identity.sign(tx);
216
217
  }
217
- async function makeSignedDelegateIntent(identity, coins, outputs, onchainOutputsIndexes, cosignerPubKeys, validAt) {
218
+ async function makeSignedDelegateIntent(identity, coins, outputs, onchainOutputsIndexes, cosignerPubKeys, validAt, destinationScript) {
218
219
  // if some of the inputs hold assets, build the asset packet and append as output
219
220
  // in the intent proof tx, there is a "fake" input at index 0
220
221
  // so the real coin indices are offset by +1
@@ -228,8 +229,11 @@ async function makeSignedDelegateIntent(identity, coins, outputs, onchainOutputs
228
229
  }
229
230
  }
230
231
  let outputAssets;
231
- let assetOutputIndex; // where to send the assets
232
+ const assetOutputIndex = findDestinationOutputIndex(outputs, destinationScript);
232
233
  if (assetInputs.size > 0) {
234
+ if (assetOutputIndex === -1) {
235
+ throw new Error("Cannot assign assets: no output matches the destination address");
236
+ }
233
237
  // collect all input assets and assign them to the first offchain output
234
238
  const allAssets = new Map();
235
239
  for (const [, assets] of assetInputs) {
@@ -242,11 +246,6 @@ async function makeSignedDelegateIntent(identity, coins, outputs, onchainOutputs
242
246
  for (const [assetId, amount] of allAssets) {
243
247
  outputAssets.push({ assetId, amount: Number(amount) });
244
248
  }
245
- const firstOffchainIndex = outputs.findIndex((_, i) => !onchainOutputsIndexes.includes(i));
246
- if (firstOffchainIndex === -1) {
247
- throw new Error("Cannot settle assets without an offchain output");
248
- }
249
- assetOutputIndex = firstOffchainIndex;
250
249
  }
251
250
  const recipients = outputs.map((output, i) => ({
252
251
  address: "", // not needed for asset packet creation
@@ -271,6 +270,13 @@ async function makeSignedDelegateIntent(identity, coins, outputs, onchainOutputs
271
270
  message,
272
271
  };
273
272
  }
273
+ /**
274
+ * Finds the index of the output whose script matches the destination script.
275
+ * Returns -1 if no match is found.
276
+ */
277
+ export function findDestinationOutputIndex(outputs, destinationScript) {
278
+ return outputs.findIndex((o) => o.script && equalBytes(o.script, destinationScript));
279
+ }
274
280
  function getDayTimestamp(timestamp) {
275
281
  const date = new Date(timestamp);
276
282
  date.setUTCHours(0, 0, 0, 0);
@@ -6,7 +6,7 @@ import { ReadonlySingleKey, SingleKey } from '../identity/index.js';
6
6
  import { ReadonlyWallet, Wallet } from '../wallet/wallet.js';
7
7
  import { hex } from "@scure/base";
8
8
  export class MessageBus {
9
- constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, debug = false, buildServices, }) {
9
+ constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, messageTimeoutMs = 30000, debug = false, buildServices, }) {
10
10
  this.walletRepository = walletRepository;
11
11
  this.contractRepository = contractRepository;
12
12
  this.running = false;
@@ -14,8 +14,10 @@ export class MessageBus {
14
14
  this.tickInProgress = false;
15
15
  this.debug = false;
16
16
  this.initialized = false;
17
+ this.boundOnMessage = this.onMessage.bind(this);
17
18
  this.handlers = new Map(messageHandlers.map((u) => [u.messageTag, u]));
18
19
  this.tickIntervalMs = tickIntervalMs;
20
+ this.messageTimeoutMs = messageTimeoutMs;
19
21
  this.debug = debug;
20
22
  this.buildServicesFn = buildServices ?? this.buildServices.bind(this);
21
23
  }
@@ -26,7 +28,7 @@ export class MessageBus {
26
28
  if (this.debug)
27
29
  console.log("MessageBus starting");
28
30
  // Hook message routing
29
- self.addEventListener("message", this.onMessage.bind(this));
31
+ self.addEventListener("message", this.boundOnMessage);
30
32
  // activate service worker immediately
31
33
  self.addEventListener("install", () => {
32
34
  self.skipWaiting();
@@ -49,7 +51,7 @@ export class MessageBus {
49
51
  self.clearTimeout(this.tickTimeout);
50
52
  this.tickTimeout = null;
51
53
  }
52
- self.removeEventListener("message", this.onMessage.bind(this));
54
+ self.removeEventListener("message", this.boundOnMessage);
53
55
  await Promise.all(Array.from(this.handlers.values()).map((updater) => updater.stop()));
54
56
  }
55
57
  scheduleNextTick() {
@@ -75,7 +77,7 @@ export class MessageBus {
75
77
  const now = Date.now();
76
78
  for (const updater of this.handlers.values()) {
77
79
  try {
78
- const response = await updater.tick(now);
80
+ const response = await this.withTimeout(updater.tick(now), `${updater.messageTag}:tick`);
79
81
  if (this.debug)
80
82
  console.log(`[${updater.messageTag}] outgoing tick response:`, response);
81
83
  if (response && response.length > 0) {
@@ -166,12 +168,25 @@ export class MessageBus {
166
168
  throw new Error("Missing privateKey or publicKey in configuration object");
167
169
  }
168
170
  }
169
- async onMessage(event) {
171
+ onMessage(event) {
172
+ // Keep the service worker alive while async work is pending.
173
+ // Without this, the browser may terminate the SW mid-operation,
174
+ // causing all pending responses to be lost silently.
175
+ const promise = this.processMessage(event);
176
+ if (typeof event.waitUntil === "function") {
177
+ event.waitUntil(promise);
178
+ }
179
+ return promise;
180
+ }
181
+ async processMessage(event) {
170
182
  const { id, tag, broadcast } = event.data;
171
183
  if (tag === "INITIALIZE_MESSAGE_BUS") {
172
184
  if (this.debug) {
173
185
  console.log("Init Command received");
174
186
  }
187
+ // Intentionally not wrapped with withTimeout: initialization
188
+ // performs network calls (buildServices) and handler startup
189
+ // that may legitimately exceed the message timeout.
175
190
  await this.waitForInit(event.data.config);
176
191
  event.source?.postMessage({ id, tag });
177
192
  if (this.debug) {
@@ -205,7 +220,7 @@ export class MessageBus {
205
220
  }
206
221
  if (broadcast) {
207
222
  const updaters = Array.from(this.handlers.values());
208
- const results = await Promise.allSettled(updaters.map((updater) => updater.handleMessage(event.data)));
223
+ const results = await Promise.allSettled(updaters.map((updater) => this.withTimeout(updater.handleMessage(event.data), updater.messageTag)));
209
224
  results.forEach((result, index) => {
210
225
  const updater = updaters[index];
211
226
  if (result.status === "fulfilled") {
@@ -236,7 +251,7 @@ export class MessageBus {
236
251
  return;
237
252
  }
238
253
  try {
239
- const response = await updater.handleMessage(event.data);
254
+ const response = await this.withTimeout(updater.handleMessage(event.data), tag);
240
255
  if (this.debug)
241
256
  console.log(`[${tag}] outgoing response:`, response);
242
257
  if (response) {
@@ -250,6 +265,27 @@ export class MessageBus {
250
265
  event.source?.postMessage({ id, tag, error });
251
266
  }
252
267
  }
268
+ /**
269
+ * Race `promise` against a timeout. Note: this does NOT cancel the
270
+ * underlying work — the original promise keeps running. This is safe
271
+ * here because only the caller (not the handler) posts the response.
272
+ */
273
+ withTimeout(promise, label) {
274
+ if (this.messageTimeoutMs <= 0)
275
+ return promise;
276
+ return new Promise((resolve, reject) => {
277
+ const timer = self.setTimeout(() => {
278
+ reject(new Error(`Message handler timed out after ${this.messageTimeoutMs}ms (${label})`));
279
+ }, this.messageTimeoutMs);
280
+ promise.then((val) => {
281
+ self.clearTimeout(timer);
282
+ resolve(val);
283
+ }, (err) => {
284
+ self.clearTimeout(timer);
285
+ reject(err);
286
+ });
287
+ });
288
+ }
253
289
  /**
254
290
  * Returns the registered SW for the path.
255
291
  * It uses the functions in `service-worker-manager.ts` module.
@@ -1,5 +1,7 @@
1
+ import { TransactionOutput } from "@scure/btc-signer/psbt";
1
2
  import { ArkProvider, DelegateInfo, ExtendedVirtualCoin, Identity, Outpoint } from "..";
2
3
  import { DelegatorProvider } from "../providers/delegator";
4
+ import { Bytes } from "@scure/btc-signer/utils";
3
5
  export interface IDelegatorManager {
4
6
  delegate(vtxos: ExtendedVirtualCoin[], destination: string, delegateAt?: Date): Promise<{
5
7
  delegated: Outpoint[];
@@ -24,3 +26,8 @@ export declare class DelegatorManagerImpl implements IDelegatorManager {
24
26
  }[];
25
27
  }>;
26
28
  }
29
+ /**
30
+ * Finds the index of the output whose script matches the destination script.
31
+ * Returns -1 if no match is found.
32
+ */
33
+ export declare function findDestinationOutputIndex(outputs: TransactionOutput[], destinationScript: Bytes): number;
@@ -48,6 +48,7 @@ export interface MessageHandler<REQ extends RequestEnvelope = RequestEnvelope, R
48
48
  type Options = {
49
49
  messageHandlers: MessageHandler[];
50
50
  tickIntervalMs?: number;
51
+ messageTimeoutMs?: number;
51
52
  debug?: boolean;
52
53
  buildServices?: (config: Initialize["config"]) => Promise<{
53
54
  arkProvider: ArkProvider;
@@ -76,13 +77,15 @@ export declare class MessageBus {
76
77
  private readonly contractRepository;
77
78
  private handlers;
78
79
  private tickIntervalMs;
80
+ private messageTimeoutMs;
79
81
  private running;
80
82
  private tickTimeout;
81
83
  private tickInProgress;
82
84
  private debug;
83
85
  private initialized;
84
86
  private readonly buildServicesFn;
85
- constructor(walletRepository: WalletRepository, contractRepository: ContractRepository, { messageHandlers, tickIntervalMs, debug, buildServices, }: Options);
87
+ private readonly boundOnMessage;
88
+ constructor(walletRepository: WalletRepository, contractRepository: ContractRepository, { messageHandlers, tickIntervalMs, messageTimeoutMs, debug, buildServices, }: Options);
86
89
  start(): Promise<void>;
87
90
  stop(): Promise<void>;
88
91
  private scheduleNextTick;
@@ -90,6 +93,13 @@ export declare class MessageBus {
90
93
  private waitForInit;
91
94
  private buildServices;
92
95
  private onMessage;
96
+ private processMessage;
97
+ /**
98
+ * Race `promise` against a timeout. Note: this does NOT cancel the
99
+ * underlying work — the original promise keeps running. This is safe
100
+ * here because only the caller (not the handler) posts the response.
101
+ */
102
+ private withTimeout;
93
103
  /**
94
104
  * Returns the registered SW for the path.
95
105
  * It uses the functions in `service-worker-manager.ts` module.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arkade-os/sdk",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Bitcoin wallet SDK with Taproot and Ark integration",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",