@arkade-os/sdk 0.0.16 → 0.1.1

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.
package/README.md CHANGED
@@ -1,7 +1,5 @@
1
- # Ark Wallet SDK
2
- The Ark Wallet SDK is a TypeScript library for building Bitcoin wallets with support for both on-chain and off-chain transactions via Ark protocol.
3
-
4
- ![v3](https://github.com/user-attachments/assets/bec6fd29-417d-46af-8216-709edc39d566)
1
+ # Arkade TypeScript SDK
2
+ The Arkade SDK is a TypeScript library for building Bitcoin wallets with support for both on-chain and off-chain transactions via the Ark protocol.
5
3
 
6
4
  ## Installation
7
5
 
@@ -92,6 +90,16 @@ console.log('History:', history)
92
90
  }
93
91
  ```
94
92
 
93
+ ### Unilateral Exit
94
+
95
+ ```typescript
96
+ // Unilateral exit all vtxos
97
+ await wallet.exit();
98
+
99
+ // Unilateral exit a specific vtxo
100
+ await wallet.exit([{ txid: vtxo.txid, vout: vtxo.vout }]);
101
+ ```
102
+
95
103
  ### Running the wallet in a service worker
96
104
 
97
105
  1. Create a service worker file
@@ -40,6 +40,23 @@ class RestArkProvider {
40
40
  spentVtxos: [...(data.spentVtxos || [])].map(convertVtxo),
41
41
  };
42
42
  }
43
+ async getRound(txid) {
44
+ const url = `${this.serverUrl}/v1/round/${txid}`;
45
+ const response = await fetch(url);
46
+ if (!response.ok) {
47
+ throw new Error(`Failed to fetch round: ${response.statusText}`);
48
+ }
49
+ const data = (await response.json());
50
+ const round = data.round;
51
+ return {
52
+ id: round.id,
53
+ start: new Date(Number(round.start) * 1000), // Convert from Unix timestamp to Date
54
+ end: new Date(Number(round.end) * 1000), // Convert from Unix timestamp to Date
55
+ vtxoTree: this.toTxTree(round.vtxoTree),
56
+ forfeitTxs: round.forfeitTxs || [],
57
+ connectors: this.toTxTree(round.connectors),
58
+ };
59
+ }
43
60
  async submitVirtualTx(psbtBase64) {
44
61
  const url = `${this.serverUrl}/v1/redeem-tx`;
45
62
  const response = await fetch(url, {
@@ -57,5 +57,17 @@ class EsploraProvider {
57
57
  }
58
58
  return response.json();
59
59
  }
60
+ async getTxStatus(txid) {
61
+ const response = await fetch(`${this.baseUrl}/tx/${txid}/status`);
62
+ if (!response.ok) {
63
+ throw new Error(`Failed to get transaction status: ${response.statusText}`);
64
+ }
65
+ const data = await response.json();
66
+ return {
67
+ confirmed: data.confirmed,
68
+ blockTime: data.block_time,
69
+ blockHeight: data.block_height,
70
+ };
71
+ }
60
72
  }
61
73
  exports.EsploraProvider = EsploraProvider;
@@ -111,6 +111,11 @@ class TxTree {
111
111
  }
112
112
  return branch;
113
113
  }
114
+ // Returns the remaining transactions to broadcast in order to exit the vtxo
115
+ async exitBranch(vtxoTxid, isTxConfirmed) {
116
+ const offchainPart = await getOffchainPart(this.branch(vtxoTxid), isTxConfirmed);
117
+ return offchainPart.map(getExitTransaction);
118
+ }
114
119
  // Helper method to find parent of a node
115
120
  findParent(node) {
116
121
  for (const level of this.tree) {
@@ -195,3 +200,32 @@ function getCosignerKeys(tx) {
195
200
  }
196
201
  return keys;
197
202
  }
203
+ async function getOffchainPart(branch, isTxConfirmed) {
204
+ let offchainPath = [...branch];
205
+ // Iterate from the end of the branch (leaf) to the beginning (root)
206
+ for (let i = branch.length - 1; i >= 0; i--) {
207
+ const node = branch[i];
208
+ // check if the transaction is confirmed on-chain
209
+ if (await isTxConfirmed(node.txid)) {
210
+ // if this is the leaf node, return empty array as everything is confirmed
211
+ if (i === branch.length - 1) {
212
+ return [];
213
+ }
214
+ // otherwise, return the unconfirmed part of the branch
215
+ return branch.slice(i + 1);
216
+ }
217
+ }
218
+ // no confirmation: everything is offchain
219
+ return offchainPath;
220
+ }
221
+ // getExitTransaction finalizes the psbt's input using the musig2 tapkey signature
222
+ function getExitTransaction(treeNode) {
223
+ const tx = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(treeNode.tx));
224
+ const input = tx.getInput(0);
225
+ if (!input.tapKeySig)
226
+ throw new TxTreeError("missing tapkey signature");
227
+ const rawTx = btc_signer_1.RawTx.decode(tx.unsignedTx);
228
+ rawTx.witnesses = [[input.tapKeySig]];
229
+ rawTx.segwitFlag = true;
230
+ return base_1.hex.encode(btc_signer_1.RawTx.encode(rawTx));
231
+ }
@@ -43,16 +43,25 @@ function addConditionWitness(inIndex, tx, witness) {
43
43
  });
44
44
  }
45
45
  function createVirtualTx(inputs, outputs) {
46
- let lockTime;
46
+ let lockTime = 0n;
47
47
  for (const input of inputs) {
48
48
  const tapscript = (0, tapscript_1.decodeTapscript)((0, base_1.scriptFromTapLeafScript)(input.tapLeafScript));
49
49
  if (tapscript_1.CLTVMultisigTapscript.is(tapscript)) {
50
- lockTime = Number(tapscript.params.absoluteTimelock);
50
+ if (lockTime !== 0n) {
51
+ // if a locktime is already set, check if the new locktime is in the same unit
52
+ if (isSeconds(lockTime) !==
53
+ isSeconds(tapscript.params.absoluteTimelock)) {
54
+ throw new Error("cannot mix seconds and blocks locktime");
55
+ }
56
+ }
57
+ if (tapscript.params.absoluteTimelock > lockTime) {
58
+ lockTime = tapscript.params.absoluteTimelock;
59
+ }
51
60
  }
52
61
  }
53
62
  const tx = new btc_signer_1.Transaction({
54
63
  allowUnknown: true,
55
- lockTime,
64
+ lockTime: Number(lockTime),
56
65
  });
57
66
  for (const [i, input] of inputs.entries()) {
58
67
  tx.addInput({
@@ -122,3 +131,7 @@ function encodeCompactSizeUint(value) {
122
131
  return buffer;
123
132
  }
124
133
  }
134
+ const nLocktimeMinSeconds = 500000000n;
135
+ function isSeconds(locktime) {
136
+ return locktime >= nLocktimeMinSeconds;
137
+ }
@@ -72,4 +72,8 @@ var Request;
72
72
  return message.type === "GET_STATUS";
73
73
  }
74
74
  Request.isGetStatus = isGetStatus;
75
+ function isExit(message) {
76
+ return message.type === "EXIT";
77
+ }
78
+ Request.isExit = isExit;
75
79
  })(Request || (exports.Request = Request = {}));
@@ -184,4 +184,12 @@ var Response;
184
184
  };
185
185
  }
186
186
  Response.clearResponse = clearResponse;
187
+ function exitSuccess(id) {
188
+ return {
189
+ type: "EXIT_SUCCESS",
190
+ success: true,
191
+ id,
192
+ };
193
+ }
194
+ Response.exitSuccess = exitSuccess;
187
195
  })(Response || (exports.Response = Response = {}));
@@ -82,20 +82,17 @@ class ServiceWorkerWallet {
82
82
  registration = await navigator.serviceWorker.register(path);
83
83
  // Handle updates
84
84
  registration.addEventListener("updatefound", () => {
85
- console.info("@arklabs/wallet-sdk: Service worker auto-update...");
86
85
  const newWorker = registration.installing;
87
86
  if (!newWorker)
88
87
  return;
89
88
  newWorker.addEventListener("statechange", () => {
90
- if (newWorker.state === "installed" &&
89
+ if (newWorker.state === "activated" &&
91
90
  navigator.serviceWorker.controller) {
92
- console.info("@arklabs/wallet-sdk: Service worker updated, reloading...");
91
+ console.info("Service worker activated, reloading...");
93
92
  window.location.reload();
94
93
  }
95
94
  });
96
95
  });
97
- // Check for updates
98
- await registration.update();
99
96
  const sw = registration.active ||
100
97
  registration.waiting ||
101
98
  registration.installing;
@@ -324,6 +321,23 @@ class ServiceWorkerWallet {
324
321
  throw new Error(`Failed to get transaction history: ${error}`);
325
322
  }
326
323
  }
324
+ async exit(outpoints) {
325
+ const message = {
326
+ type: "EXIT",
327
+ outpoints,
328
+ id: getRandomId(),
329
+ };
330
+ try {
331
+ const response = await this.sendMessage(message);
332
+ if (response.type === "EXIT_SUCCESS") {
333
+ return;
334
+ }
335
+ throw new UnexpectedResponseError(response);
336
+ }
337
+ catch (error) {
338
+ throw new Error(`Failed to exit: ${error}`);
339
+ }
340
+ }
327
341
  }
328
342
  exports.ServiceWorkerWallet = ServiceWorkerWallet;
329
343
  function getRandomId() {
@@ -17,10 +17,20 @@ class Worker {
17
17
  this.vtxoRepository = vtxoRepository;
18
18
  this.messageCallback = messageCallback;
19
19
  }
20
- async start() {
20
+ async start(withServiceWorkerUpdate = true) {
21
21
  self.addEventListener("message", async (event) => {
22
22
  await this.handleMessage(event);
23
23
  });
24
+ if (withServiceWorkerUpdate) {
25
+ // activate service worker immediately
26
+ self.addEventListener("install", () => {
27
+ self.skipWaiting();
28
+ });
29
+ // take control of clients immediately
30
+ self.addEventListener("activate", () => {
31
+ self.clients.claim();
32
+ });
33
+ }
24
34
  }
25
35
  async clear() {
26
36
  if (this.vtxoSubscription) {
@@ -387,6 +397,30 @@ class Worker {
387
397
  }
388
398
  event.source?.postMessage(response_1.Response.walletStatus(message.id, this.wallet !== undefined));
389
399
  }
400
+ async handleExit(event) {
401
+ const message = event.data;
402
+ if (!request_1.Request.isExit(message)) {
403
+ console.error("Invalid EXIT message format", message);
404
+ event.source?.postMessage(response_1.Response.error(message.id, "Invalid EXIT message format"));
405
+ return;
406
+ }
407
+ if (!this.wallet) {
408
+ console.error("Wallet not initialized");
409
+ event.source?.postMessage(response_1.Response.error(message.id, "Wallet not initialized"));
410
+ return;
411
+ }
412
+ try {
413
+ await this.wallet.exit(message.outpoints);
414
+ event.source?.postMessage(response_1.Response.exitSuccess(message.id));
415
+ }
416
+ catch (error) {
417
+ console.error("Error exiting:", error);
418
+ const errorMessage = error instanceof Error
419
+ ? error.message
420
+ : "Unknown error occurred";
421
+ event.source?.postMessage(response_1.Response.error(message.id, errorMessage));
422
+ }
423
+ }
390
424
  async handleMessage(event) {
391
425
  this.messageCallback(event);
392
426
  const message = event.data;
@@ -440,6 +474,10 @@ class Worker {
440
474
  await this.handleGetStatus(event);
441
475
  break;
442
476
  }
477
+ case "EXIT": {
478
+ await this.handleExit(event);
479
+ break;
480
+ }
443
481
  case "CLEAR": {
444
482
  await this.handleClear(event);
445
483
  break;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Wallet = void 0;
4
4
  const base_1 = require("@scure/base");
5
5
  const payment_1 = require("@scure/btc-signer/payment");
6
+ const btc_signer_1 = require("@scure/btc-signer");
6
7
  const psbt_1 = require("@scure/btc-signer/psbt");
7
8
  const transactionHistory_1 = require("../utils/transactionHistory");
8
9
  const bip21_1 = require("../utils/bip21");
@@ -19,7 +20,6 @@ const _1 = require(".");
19
20
  const base_2 = require("../script/base");
20
21
  const tapscript_1 = require("../script/tapscript");
21
22
  const psbt_2 = require("../utils/psbt");
22
- const btc_signer_1 = require("@scure/btc-signer");
23
23
  const arknote_1 = require("../arknote");
24
24
  // Wallet does not store any data and rely on the Ark and onchain providers to fetch utxos and vtxos
25
25
  class Wallet {
@@ -369,7 +369,6 @@ class Wallet {
369
369
  amount: BigInt(input.value),
370
370
  },
371
371
  tapInternalKey: this.onchainP2TR.tapInternalKey,
372
- tapMerkleRoot: this.onchainP2TR.tapMerkleRoot,
373
372
  });
374
373
  }
375
374
  // Add payment output
@@ -585,6 +584,48 @@ class Wallet {
585
584
  }
586
585
  throw new Error("Settlement failed");
587
586
  }
587
+ async exit(outpoints) {
588
+ // TODO store the exit branches in repository
589
+ // exit should not depend on the ark provider
590
+ if (!this.arkProvider) {
591
+ throw new Error("Ark provider not configured");
592
+ }
593
+ let vtxos = await this.getVtxos();
594
+ if (outpoints && outpoints.length > 0) {
595
+ vtxos = vtxos.filter((vtxo) => outpoints.some((outpoint) => vtxo.txid === outpoint.txid &&
596
+ vtxo.vout === outpoint.vout));
597
+ }
598
+ if (vtxos.length === 0) {
599
+ throw new Error("No vtxos to exit");
600
+ }
601
+ const trees = new Map();
602
+ const transactions = [];
603
+ for (const vtxo of vtxos) {
604
+ const batchTxid = vtxo.virtualStatus.batchTxID;
605
+ if (!batchTxid)
606
+ continue;
607
+ if (!trees.has(batchTxid)) {
608
+ const round = await this.arkProvider.getRound(batchTxid);
609
+ trees.set(batchTxid, round.vtxoTree);
610
+ }
611
+ const tree = trees.get(batchTxid);
612
+ if (!tree) {
613
+ throw new Error("Tree not found");
614
+ }
615
+ const exitBranch = await tree.exitBranch(vtxo.txid, async (txid) => {
616
+ const status = await this.onchainProvider.getTxStatus(txid);
617
+ return status.confirmed;
618
+ });
619
+ transactions.push(...exitBranch);
620
+ }
621
+ const broadcastedTxs = new Map();
622
+ for (const tx of transactions) {
623
+ if (broadcastedTxs.has(tx))
624
+ continue;
625
+ const txid = await this.onchainProvider.broadcastTransaction(tx);
626
+ broadcastedTxs.set(txid, true);
627
+ }
628
+ }
588
629
  // validates the vtxo tree, creates a signing session and generates the musig2 nonces
589
630
  async handleSettlementSigningEvent(event, sweepTapTreeRoot, session) {
590
631
  const vtxoTree = event.unsignedVtxoTree;
@@ -37,6 +37,23 @@ export class RestArkProvider {
37
37
  spentVtxos: [...(data.spentVtxos || [])].map(convertVtxo),
38
38
  };
39
39
  }
40
+ async getRound(txid) {
41
+ const url = `${this.serverUrl}/v1/round/${txid}`;
42
+ const response = await fetch(url);
43
+ if (!response.ok) {
44
+ throw new Error(`Failed to fetch round: ${response.statusText}`);
45
+ }
46
+ const data = (await response.json());
47
+ const round = data.round;
48
+ return {
49
+ id: round.id,
50
+ start: new Date(Number(round.start) * 1000), // Convert from Unix timestamp to Date
51
+ end: new Date(Number(round.end) * 1000), // Convert from Unix timestamp to Date
52
+ vtxoTree: this.toTxTree(round.vtxoTree),
53
+ forfeitTxs: round.forfeitTxs || [],
54
+ connectors: this.toTxTree(round.connectors),
55
+ };
56
+ }
40
57
  async submitVirtualTx(psbtBase64) {
41
58
  const url = `${this.serverUrl}/v1/redeem-tx`;
42
59
  const response = await fetch(url, {
@@ -54,4 +54,16 @@ export class EsploraProvider {
54
54
  }
55
55
  return response.json();
56
56
  }
57
+ async getTxStatus(txid) {
58
+ const response = await fetch(`${this.baseUrl}/tx/${txid}/status`);
59
+ if (!response.ok) {
60
+ throw new Error(`Failed to get transaction status: ${response.statusText}`);
61
+ }
62
+ const data = await response.json();
63
+ return {
64
+ confirmed: data.confirmed,
65
+ blockTime: data.block_time,
66
+ blockHeight: data.block_height,
67
+ };
68
+ }
57
69
  }
@@ -1,5 +1,5 @@
1
1
  import * as bip68 from "bip68";
2
- import { ScriptNum, Transaction } from "@scure/btc-signer";
2
+ import { RawTx, ScriptNum, Transaction } from "@scure/btc-signer";
3
3
  import { sha256x2 } from "@scure/btc-signer/utils";
4
4
  import { base64, hex } from "@scure/base";
5
5
  export class TxTreeError extends Error {
@@ -72,6 +72,11 @@ export class TxTree {
72
72
  }
73
73
  return branch;
74
74
  }
75
+ // Returns the remaining transactions to broadcast in order to exit the vtxo
76
+ async exitBranch(vtxoTxid, isTxConfirmed) {
77
+ const offchainPart = await getOffchainPart(this.branch(vtxoTxid), isTxConfirmed);
78
+ return offchainPart.map(getExitTransaction);
79
+ }
75
80
  // Helper method to find parent of a node
76
81
  findParent(node) {
77
82
  for (const level of this.tree) {
@@ -155,3 +160,32 @@ export function getCosignerKeys(tx) {
155
160
  }
156
161
  return keys;
157
162
  }
163
+ async function getOffchainPart(branch, isTxConfirmed) {
164
+ let offchainPath = [...branch];
165
+ // Iterate from the end of the branch (leaf) to the beginning (root)
166
+ for (let i = branch.length - 1; i >= 0; i--) {
167
+ const node = branch[i];
168
+ // check if the transaction is confirmed on-chain
169
+ if (await isTxConfirmed(node.txid)) {
170
+ // if this is the leaf node, return empty array as everything is confirmed
171
+ if (i === branch.length - 1) {
172
+ return [];
173
+ }
174
+ // otherwise, return the unconfirmed part of the branch
175
+ return branch.slice(i + 1);
176
+ }
177
+ }
178
+ // no confirmation: everything is offchain
179
+ return offchainPath;
180
+ }
181
+ // getExitTransaction finalizes the psbt's input using the musig2 tapkey signature
182
+ function getExitTransaction(treeNode) {
183
+ const tx = Transaction.fromPSBT(base64.decode(treeNode.tx));
184
+ const input = tx.getInput(0);
185
+ if (!input.tapKeySig)
186
+ throw new TxTreeError("missing tapkey signature");
187
+ const rawTx = RawTx.decode(tx.unsignedTx);
188
+ rawTx.witnesses = [[input.tapKeySig]];
189
+ rawTx.segwitFlag = true;
190
+ return hex.encode(RawTx.encode(rawTx));
191
+ }
@@ -37,16 +37,25 @@ export function addConditionWitness(inIndex, tx, witness) {
37
37
  });
38
38
  }
39
39
  export function createVirtualTx(inputs, outputs) {
40
- let lockTime;
40
+ let lockTime = 0n;
41
41
  for (const input of inputs) {
42
42
  const tapscript = decodeTapscript(scriptFromTapLeafScript(input.tapLeafScript));
43
43
  if (CLTVMultisigTapscript.is(tapscript)) {
44
- lockTime = Number(tapscript.params.absoluteTimelock);
44
+ if (lockTime !== 0n) {
45
+ // if a locktime is already set, check if the new locktime is in the same unit
46
+ if (isSeconds(lockTime) !==
47
+ isSeconds(tapscript.params.absoluteTimelock)) {
48
+ throw new Error("cannot mix seconds and blocks locktime");
49
+ }
50
+ }
51
+ if (tapscript.params.absoluteTimelock > lockTime) {
52
+ lockTime = tapscript.params.absoluteTimelock;
53
+ }
45
54
  }
46
55
  }
47
56
  const tx = new Transaction({
48
57
  allowUnknown: true,
49
- lockTime,
58
+ lockTime: Number(lockTime),
50
59
  });
51
60
  for (const [i, input] of inputs.entries()) {
52
61
  tx.addInput({
@@ -116,3 +125,7 @@ function encodeCompactSizeUint(value) {
116
125
  return buffer;
117
126
  }
118
127
  }
128
+ const nLocktimeMinSeconds = 500000000n;
129
+ function isSeconds(locktime) {
130
+ return locktime >= nLocktimeMinSeconds;
131
+ }
@@ -69,4 +69,8 @@ export var Request;
69
69
  return message.type === "GET_STATUS";
70
70
  }
71
71
  Request.isGetStatus = isGetStatus;
72
+ function isExit(message) {
73
+ return message.type === "EXIT";
74
+ }
75
+ Request.isExit = isExit;
72
76
  })(Request || (Request = {}));
@@ -181,4 +181,12 @@ export var Response;
181
181
  };
182
182
  }
183
183
  Response.clearResponse = clearResponse;
184
+ function exitSuccess(id) {
185
+ return {
186
+ type: "EXIT_SUCCESS",
187
+ success: true,
188
+ id,
189
+ };
190
+ }
191
+ Response.exitSuccess = exitSuccess;
184
192
  })(Response || (Response = {}));
@@ -79,20 +79,17 @@ export class ServiceWorkerWallet {
79
79
  registration = await navigator.serviceWorker.register(path);
80
80
  // Handle updates
81
81
  registration.addEventListener("updatefound", () => {
82
- console.info("@arklabs/wallet-sdk: Service worker auto-update...");
83
82
  const newWorker = registration.installing;
84
83
  if (!newWorker)
85
84
  return;
86
85
  newWorker.addEventListener("statechange", () => {
87
- if (newWorker.state === "installed" &&
86
+ if (newWorker.state === "activated" &&
88
87
  navigator.serviceWorker.controller) {
89
- console.info("@arklabs/wallet-sdk: Service worker updated, reloading...");
88
+ console.info("Service worker activated, reloading...");
90
89
  window.location.reload();
91
90
  }
92
91
  });
93
92
  });
94
- // Check for updates
95
- await registration.update();
96
93
  const sw = registration.active ||
97
94
  registration.waiting ||
98
95
  registration.installing;
@@ -321,6 +318,23 @@ export class ServiceWorkerWallet {
321
318
  throw new Error(`Failed to get transaction history: ${error}`);
322
319
  }
323
320
  }
321
+ async exit(outpoints) {
322
+ const message = {
323
+ type: "EXIT",
324
+ outpoints,
325
+ id: getRandomId(),
326
+ };
327
+ try {
328
+ const response = await this.sendMessage(message);
329
+ if (response.type === "EXIT_SUCCESS") {
330
+ return;
331
+ }
332
+ throw new UnexpectedResponseError(response);
333
+ }
334
+ catch (error) {
335
+ throw new Error(`Failed to exit: ${error}`);
336
+ }
337
+ }
324
338
  }
325
339
  function getRandomId() {
326
340
  const randomValue = crypto.getRandomValues(new Uint8Array(16));
@@ -14,10 +14,20 @@ export class Worker {
14
14
  this.vtxoRepository = vtxoRepository;
15
15
  this.messageCallback = messageCallback;
16
16
  }
17
- async start() {
17
+ async start(withServiceWorkerUpdate = true) {
18
18
  self.addEventListener("message", async (event) => {
19
19
  await this.handleMessage(event);
20
20
  });
21
+ if (withServiceWorkerUpdate) {
22
+ // activate service worker immediately
23
+ self.addEventListener("install", () => {
24
+ self.skipWaiting();
25
+ });
26
+ // take control of clients immediately
27
+ self.addEventListener("activate", () => {
28
+ self.clients.claim();
29
+ });
30
+ }
21
31
  }
22
32
  async clear() {
23
33
  if (this.vtxoSubscription) {
@@ -384,6 +394,30 @@ export class Worker {
384
394
  }
385
395
  event.source?.postMessage(Response.walletStatus(message.id, this.wallet !== undefined));
386
396
  }
397
+ async handleExit(event) {
398
+ const message = event.data;
399
+ if (!Request.isExit(message)) {
400
+ console.error("Invalid EXIT message format", message);
401
+ event.source?.postMessage(Response.error(message.id, "Invalid EXIT message format"));
402
+ return;
403
+ }
404
+ if (!this.wallet) {
405
+ console.error("Wallet not initialized");
406
+ event.source?.postMessage(Response.error(message.id, "Wallet not initialized"));
407
+ return;
408
+ }
409
+ try {
410
+ await this.wallet.exit(message.outpoints);
411
+ event.source?.postMessage(Response.exitSuccess(message.id));
412
+ }
413
+ catch (error) {
414
+ console.error("Error exiting:", error);
415
+ const errorMessage = error instanceof Error
416
+ ? error.message
417
+ : "Unknown error occurred";
418
+ event.source?.postMessage(Response.error(message.id, errorMessage));
419
+ }
420
+ }
387
421
  async handleMessage(event) {
388
422
  this.messageCallback(event);
389
423
  const message = event.data;
@@ -437,6 +471,10 @@ export class Worker {
437
471
  await this.handleGetStatus(event);
438
472
  break;
439
473
  }
474
+ case "EXIT": {
475
+ await this.handleExit(event);
476
+ break;
477
+ }
440
478
  case "CLEAR": {
441
479
  await this.handleClear(event);
442
480
  break;
@@ -1,5 +1,6 @@
1
1
  import { base64, hex } from "@scure/base";
2
2
  import { Address, OutScript, p2tr, tapLeafHash, } from "@scure/btc-signer/payment";
3
+ import { Transaction } from "@scure/btc-signer";
3
4
  import { TaprootControlBlock } from "@scure/btc-signer/psbt";
4
5
  import { vtxosToTxs } from '../utils/transactionHistory.js';
5
6
  import { BIP21 } from '../utils/bip21.js';
@@ -16,7 +17,6 @@ import { TxType, } from './index.js';
16
17
  import { scriptFromTapLeafScript, VtxoScript } from '../script/base.js';
17
18
  import { CSVMultisigTapscript, decodeTapscript, } from '../script/tapscript.js';
18
19
  import { createVirtualTx } from '../utils/psbt.js';
19
- import { Transaction } from "@scure/btc-signer";
20
20
  import { ArkNote } from '../arknote/index.js';
21
21
  // Wallet does not store any data and rely on the Ark and onchain providers to fetch utxos and vtxos
22
22
  export class Wallet {
@@ -366,7 +366,6 @@ export class Wallet {
366
366
  amount: BigInt(input.value),
367
367
  },
368
368
  tapInternalKey: this.onchainP2TR.tapInternalKey,
369
- tapMerkleRoot: this.onchainP2TR.tapMerkleRoot,
370
369
  });
371
370
  }
372
371
  // Add payment output
@@ -582,6 +581,48 @@ export class Wallet {
582
581
  }
583
582
  throw new Error("Settlement failed");
584
583
  }
584
+ async exit(outpoints) {
585
+ // TODO store the exit branches in repository
586
+ // exit should not depend on the ark provider
587
+ if (!this.arkProvider) {
588
+ throw new Error("Ark provider not configured");
589
+ }
590
+ let vtxos = await this.getVtxos();
591
+ if (outpoints && outpoints.length > 0) {
592
+ vtxos = vtxos.filter((vtxo) => outpoints.some((outpoint) => vtxo.txid === outpoint.txid &&
593
+ vtxo.vout === outpoint.vout));
594
+ }
595
+ if (vtxos.length === 0) {
596
+ throw new Error("No vtxos to exit");
597
+ }
598
+ const trees = new Map();
599
+ const transactions = [];
600
+ for (const vtxo of vtxos) {
601
+ const batchTxid = vtxo.virtualStatus.batchTxID;
602
+ if (!batchTxid)
603
+ continue;
604
+ if (!trees.has(batchTxid)) {
605
+ const round = await this.arkProvider.getRound(batchTxid);
606
+ trees.set(batchTxid, round.vtxoTree);
607
+ }
608
+ const tree = trees.get(batchTxid);
609
+ if (!tree) {
610
+ throw new Error("Tree not found");
611
+ }
612
+ const exitBranch = await tree.exitBranch(vtxo.txid, async (txid) => {
613
+ const status = await this.onchainProvider.getTxStatus(txid);
614
+ return status.confirmed;
615
+ });
616
+ transactions.push(...exitBranch);
617
+ }
618
+ const broadcastedTxs = new Map();
619
+ for (const tx of transactions) {
620
+ if (broadcastedTxs.has(tx))
621
+ continue;
622
+ const txid = await this.onchainProvider.broadcastTransaction(tx);
623
+ broadcastedTxs.set(txid, true);
624
+ }
625
+ }
585
626
  // validates the vtxo tree, creates a signing session and generates the musig2 nonces
586
627
  async handleSettlementSigningEvent(event, sweepTapTreeRoot, session) {
587
628
  const vtxoTree = event.unsignedVtxoTree;
@@ -75,8 +75,17 @@ export interface ArkInfo {
75
75
  end: number;
76
76
  };
77
77
  }
78
+ export interface Round {
79
+ id: string;
80
+ start: Date;
81
+ end: Date;
82
+ vtxoTree: TxTree;
83
+ forfeitTxs: string[];
84
+ connectors: TxTree;
85
+ }
78
86
  export interface ArkProvider {
79
87
  getInfo(): Promise<ArkInfo>;
88
+ getRound(txid: string): Promise<Round>;
80
89
  getVirtualCoins(address: string): Promise<{
81
90
  spendableVtxos: VirtualCoin[];
82
91
  spentVtxos: VirtualCoin[];
@@ -105,6 +114,7 @@ export declare class RestArkProvider implements ArkProvider {
105
114
  spendableVtxos: VirtualCoin[];
106
115
  spentVtxos: VirtualCoin[];
107
116
  }>;
117
+ getRound(txid: string): Promise<Round>;
108
118
  submitVirtualTx(psbtBase64: string): Promise<string>;
109
119
  subscribeToEvents(callback: (event: ArkEvent) => void): Promise<() => void>;
110
120
  registerInputsForNextRound(inputs: Input[]): Promise<{
@@ -21,6 +21,11 @@ export interface OnchainProvider {
21
21
  txid: string;
22
22
  }[]>;
23
23
  getTransactions(address: string): Promise<ExplorerTransaction[]>;
24
+ getTxStatus(txid: string): Promise<{
25
+ confirmed: boolean;
26
+ blockTime?: number;
27
+ blockHeight?: number;
28
+ }>;
24
29
  }
25
30
  export declare class EsploraProvider implements OnchainProvider {
26
31
  private baseUrl;
@@ -33,4 +38,9 @@ export declare class EsploraProvider implements OnchainProvider {
33
38
  txid: string;
34
39
  }[]>;
35
40
  getTransactions(address: string): Promise<ExplorerTransaction[]>;
41
+ getTxStatus(txid: string): Promise<{
42
+ confirmed: boolean;
43
+ blockTime?: number;
44
+ blockHeight?: number;
45
+ }>;
36
46
  }
@@ -20,6 +20,7 @@ export declare class TxTree {
20
20
  children(nodeTxid: string): TreeNode[];
21
21
  numberOfNodes(): number;
22
22
  branch(vtxoTxid: string): TreeNode[];
23
+ exitBranch(vtxoTxid: string, isTxConfirmed: (txid: string) => Promise<boolean>): Promise<string[]>;
23
24
  private findParent;
24
25
  validate(): void;
25
26
  }
@@ -119,4 +119,5 @@ export interface IWallet {
119
119
  getTransactionHistory(): Promise<ArkTransaction[]>;
120
120
  sendBitcoin(params: SendBitcoinParams, zeroFee?: boolean): Promise<string>;
121
121
  settle(params?: SettleParams, eventCallback?: (event: SettlementEvent) => void): Promise<string>;
122
+ exit(outpoints?: Outpoint[]): Promise<void>;
122
123
  }
@@ -1,7 +1,7 @@
1
1
  import { NetworkName } from "../../networks";
2
- import { SettleParams, SendBitcoinParams } from "..";
2
+ import { SettleParams, SendBitcoinParams, Outpoint } from "..";
3
3
  export declare namespace Request {
4
- type Type = "INIT_WALLET" | "SETTLE" | "GET_ADDRESS" | "GET_ADDRESS_INFO" | "GET_BALANCE" | "GET_COINS" | "GET_VTXOS" | "GET_VIRTUAL_COINS" | "GET_BOARDING_UTXOS" | "SEND_BITCOIN" | "GET_TRANSACTION_HISTORY" | "GET_STATUS" | "CLEAR";
4
+ type Type = "INIT_WALLET" | "SETTLE" | "GET_ADDRESS" | "GET_ADDRESS_INFO" | "GET_BALANCE" | "GET_COINS" | "GET_VTXOS" | "GET_VIRTUAL_COINS" | "GET_BOARDING_UTXOS" | "SEND_BITCOIN" | "GET_TRANSACTION_HISTORY" | "GET_STATUS" | "CLEAR" | "EXIT";
5
5
  interface Base {
6
6
  type: Type;
7
7
  id: string;
@@ -65,4 +65,9 @@ export declare namespace Request {
65
65
  interface Clear extends Base {
66
66
  type: "CLEAR";
67
67
  }
68
+ interface Exit extends Base {
69
+ type: "EXIT";
70
+ outpoints?: Outpoint[];
71
+ }
72
+ function isExit(message: Base): message is Exit;
68
73
  }
@@ -1,7 +1,7 @@
1
1
  import { WalletBalance, Coin, VirtualCoin, ArkTransaction, AddressInfo as WalletAddressInfo, IWallet, Addresses } from "..";
2
2
  import { SettlementEvent } from "../../providers/ark";
3
3
  export declare namespace Response {
4
- type Type = "WALLET_INITIALIZED" | "SETTLE_EVENT" | "SETTLE_SUCCESS" | "ADDRESS" | "ADDRESS_INFO" | "BALANCE" | "COINS" | "VTXOS" | "VIRTUAL_COINS" | "BOARDING_UTXOS" | "SEND_BITCOIN_SUCCESS" | "TRANSACTION_HISTORY" | "WALLET_STATUS" | "ERROR" | "CLEAR_RESPONSE";
4
+ type Type = "WALLET_INITIALIZED" | "SETTLE_EVENT" | "SETTLE_SUCCESS" | "ADDRESS" | "ADDRESS_INFO" | "BALANCE" | "COINS" | "VTXOS" | "VIRTUAL_COINS" | "BOARDING_UTXOS" | "SEND_BITCOIN_SUCCESS" | "TRANSACTION_HISTORY" | "WALLET_STATUS" | "ERROR" | "CLEAR_RESPONSE" | "EXIT_SUCCESS";
5
5
  interface Base {
6
6
  type: Type;
7
7
  success: boolean;
@@ -104,4 +104,9 @@ export declare namespace Response {
104
104
  }
105
105
  function isClearResponse(response: Base): response is ClearResponse;
106
106
  function clearResponse(id: string, success: boolean): ClearResponse;
107
+ interface ExitSuccess extends Base {
108
+ type: "EXIT_SUCCESS";
109
+ success: true;
110
+ }
111
+ function exitSuccess(id: string): ExitSuccess;
107
112
  }
@@ -1,4 +1,4 @@
1
- import { IWallet, WalletBalance, SendBitcoinParams, SettleParams, AddressInfo, Coin, ArkTransaction, WalletConfig, ExtendedCoin, ExtendedVirtualCoin, Addresses } from "..";
1
+ import { IWallet, WalletBalance, SendBitcoinParams, SettleParams, AddressInfo, Coin, ArkTransaction, WalletConfig, ExtendedCoin, ExtendedVirtualCoin, Addresses, Outpoint } from "..";
2
2
  import { Response } from "./response";
3
3
  import { SettlementEvent } from "../../providers/ark";
4
4
  export declare class ServiceWorkerWallet implements IWallet {
@@ -20,4 +20,5 @@ export declare class ServiceWorkerWallet implements IWallet {
20
20
  sendBitcoin(params: SendBitcoinParams, zeroFee?: boolean): Promise<string>;
21
21
  settle(params?: SettleParams, callback?: (event: SettlementEvent) => void): Promise<string>;
22
22
  getTransactionHistory(): Promise<ArkTransaction[]>;
23
+ exit(outpoints?: Outpoint[]): Promise<void>;
23
24
  }
@@ -6,7 +6,7 @@ export declare class Worker {
6
6
  private arkProvider;
7
7
  private vtxoSubscription;
8
8
  constructor(vtxoRepository?: VtxoRepository, messageCallback?: (message: ExtendableMessageEvent) => void);
9
- start(): Promise<void>;
9
+ start(withServiceWorkerUpdate?: boolean): Promise<void>;
10
10
  clear(): Promise<void>;
11
11
  private onWalletInitialized;
12
12
  private processVtxoSubscription;
@@ -22,5 +22,6 @@ export declare class Worker {
22
22
  private handleGetBoardingUtxos;
23
23
  private handleGetTransactionHistory;
24
24
  private handleGetStatus;
25
+ private handleExit;
25
26
  private handleMessage;
26
27
  }
@@ -1,7 +1,7 @@
1
1
  import { ArkAddress } from "../script/address";
2
2
  import { DefaultVtxo } from "../script/default";
3
3
  import { SettlementEvent } from "../providers/ark";
4
- import { Addresses, AddressInfo, ArkTransaction, Coin, ExtendedCoin, ExtendedVirtualCoin, IWallet, SendBitcoinParams, SettleParams, WalletBalance, WalletConfig } from ".";
4
+ import { Addresses, AddressInfo, ArkTransaction, Coin, ExtendedCoin, ExtendedVirtualCoin, IWallet, Outpoint, SendBitcoinParams, SettleParams, WalletBalance, WalletConfig } from ".";
5
5
  export declare class Wallet implements IWallet {
6
6
  private identity;
7
7
  private network;
@@ -36,6 +36,7 @@ export declare class Wallet implements IWallet {
36
36
  private sendOnchain;
37
37
  private sendOffchain;
38
38
  settle(params?: SettleParams, eventCallback?: (event: SettlementEvent) => void): Promise<string>;
39
+ exit(outpoints?: Outpoint[]): Promise<void>;
39
40
  private handleSettlementSigningEvent;
40
41
  private handleSettlementSigningNoncesGeneratedEvent;
41
42
  private handleSettlementFinalizationEvent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arkade-os/sdk",
3
- "version": "0.0.16",
3
+ "version": "0.1.1",
4
4
  "description": "Bitcoin wallet SDK with Taproot and Ark integration",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",
@@ -23,11 +23,11 @@
23
23
  "registry": "https://registry.npmjs.org/"
24
24
  },
25
25
  "dependencies": {
26
- "@noble/curves": "1.7.0",
27
- "@noble/hashes": "1.6.1",
26
+ "@noble/curves": "1.9.1",
27
+ "@noble/hashes": "1.8.0",
28
28
  "@noble/secp256k1": "2.2.3",
29
- "@scure/base": "1.2.1",
30
- "@scure/btc-signer": "1.7.0",
29
+ "@scure/base": "1.2.6",
30
+ "@scure/btc-signer": "1.8.1",
31
31
  "bip68": "1.0.4"
32
32
  },
33
33
  "devDependencies": {
@@ -36,7 +36,7 @@
36
36
  "@types/node": "22.10.2",
37
37
  "@typescript-eslint/eslint-plugin": "8.18.2",
38
38
  "@typescript-eslint/parser": "8.18.2",
39
- "@vitest/coverage-v8": "2.1.8",
39
+ "@vitest/coverage-v8": "2.1.9",
40
40
  "esbuild": "^0.20.1",
41
41
  "eslint": "^9.17.0",
42
42
  "glob": "11.0.1",
@@ -44,7 +44,7 @@
44
44
  "lint-staged": "15.3.0",
45
45
  "prettier": "3.4.2",
46
46
  "typescript": "5.7.2",
47
- "vitest": "2.1.8"
47
+ "vitest": "2.1.9"
48
48
  },
49
49
  "keywords": [
50
50
  "bitcoin",