@arkade-os/sdk 0.3.3 → 0.3.5

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.
@@ -146,12 +146,29 @@ class Wallet {
146
146
  const esploraUrl = config.esploraUrl || onchain_1.ESPLORA_URL[info.network];
147
147
  // Use provided onchainProvider instance or create a new one
148
148
  const onchainProvider = config.onchainProvider || new onchain_1.EsploraProvider(esploraUrl);
149
- // Generate timelocks
150
- const exitTimelock = {
149
+ // validate unilateral exit timelock passed in config if any
150
+ if (config.exitTimelock) {
151
+ const { value, type } = config.exitTimelock;
152
+ if ((value < 512n && type !== "blocks") ||
153
+ (value >= 512n && type !== "seconds")) {
154
+ throw new Error("invalid exitTimelock");
155
+ }
156
+ }
157
+ // create unilateral exit timelock
158
+ const exitTimelock = config.exitTimelock ?? {
151
159
  value: info.unilateralExitDelay,
152
160
  type: info.unilateralExitDelay < 512n ? "blocks" : "seconds",
153
161
  };
154
- const boardingTimelock = {
162
+ // validate boarding timelock passed in config if any
163
+ if (config.boardingTimelock) {
164
+ const { value, type } = config.boardingTimelock;
165
+ if ((value < 512n && type !== "blocks") ||
166
+ (value >= 512n && type !== "seconds")) {
167
+ throw new Error("invalid boardingTimelock");
168
+ }
169
+ }
170
+ // create boarding timelock
171
+ const boardingTimelock = config.boardingTimelock ?? {
155
172
  value: info.boardingExitDelay,
156
173
  type: info.boardingExitDelay < 512n ? "blocks" : "seconds",
157
174
  };
@@ -414,14 +431,13 @@ class Wallet {
414
431
  });
415
432
  }
416
433
  const tapTree = this.offchainTapscript.encode();
417
- let offchainTx = (0, arkTransaction_1.buildOffchainTx)(selected.inputs.map((input) => ({
434
+ const offchainTx = (0, arkTransaction_1.buildOffchainTx)(selected.inputs.map((input) => ({
418
435
  ...input,
419
436
  tapLeafScript: selectedLeaf,
420
437
  tapTree,
421
438
  })), outputs, this.serverUnrollScript);
422
439
  const signedVirtualTx = await this.identity.sign(offchainTx.arkTx);
423
440
  const { arkTxid, signedCheckpointTxs } = await this.arkProvider.submitTx(base_1.base64.encode(signedVirtualTx.toPSBT()), offchainTx.checkpoints.map((c) => base_1.base64.encode(c.toPSBT())));
424
- // TODO persist final virtual tx and checkpoints to repository
425
441
  // sign the checkpoints
426
442
  const finalCheckpoints = await Promise.all(signedCheckpointTxs.map(async (c) => {
427
443
  const tx = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(c));
@@ -429,7 +445,78 @@ class Wallet {
429
445
  return base_1.base64.encode(signedCheckpoint.toPSBT());
430
446
  }));
431
447
  await this.arkProvider.finalizeTx(arkTxid, finalCheckpoints);
432
- return arkTxid;
448
+ try {
449
+ // mark VTXOs as spent and optionally add the change VTXO
450
+ const spentVtxos = [];
451
+ const commitmentTxIds = new Set();
452
+ let batchExpiry = Number.MAX_SAFE_INTEGER;
453
+ for (const [inputIndex, input] of selected.inputs.entries()) {
454
+ const vtxo = (0, utils_1.extendVirtualCoin)(this, input);
455
+ const checkpointB64 = signedCheckpointTxs[inputIndex];
456
+ const checkpoint = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(checkpointB64));
457
+ spentVtxos.push({
458
+ ...vtxo,
459
+ virtualStatus: { ...vtxo.virtualStatus, state: "spent" },
460
+ spentBy: checkpoint.id,
461
+ arkTxId: arkTxid,
462
+ isSpent: true,
463
+ });
464
+ if (vtxo.virtualStatus.commitmentTxIds) {
465
+ for (const commitmentTxId of vtxo.virtualStatus
466
+ .commitmentTxIds) {
467
+ commitmentTxIds.add(commitmentTxId);
468
+ }
469
+ }
470
+ if (vtxo.virtualStatus.batchExpiry) {
471
+ batchExpiry = Math.min(batchExpiry, vtxo.virtualStatus.batchExpiry);
472
+ }
473
+ }
474
+ const createdAt = Date.now();
475
+ const addr = this.arkAddress.encode();
476
+ if (selected.changeAmount > 0n &&
477
+ batchExpiry !== Number.MAX_SAFE_INTEGER) {
478
+ const changeVtxo = {
479
+ txid: arkTxid,
480
+ vout: outputs.length - 1,
481
+ createdAt: new Date(createdAt),
482
+ forfeitTapLeafScript: this.offchainTapscript.forfeit(),
483
+ intentTapLeafScript: this.offchainTapscript.exit(),
484
+ isUnrolled: false,
485
+ isSpent: false,
486
+ tapTree: this.offchainTapscript.encode(),
487
+ value: Number(selected.changeAmount),
488
+ virtualStatus: {
489
+ state: "preconfirmed",
490
+ commitmentTxIds: Array.from(commitmentTxIds),
491
+ batchExpiry,
492
+ },
493
+ status: {
494
+ confirmed: false,
495
+ },
496
+ };
497
+ await this.walletRepository.saveVtxos(addr, [changeVtxo]);
498
+ }
499
+ await this.walletRepository.saveVtxos(addr, spentVtxos);
500
+ await this.walletRepository.saveTransactions(addr, [
501
+ {
502
+ key: {
503
+ boardingTxid: "",
504
+ commitmentTxid: "",
505
+ arkTxid: arkTxid,
506
+ },
507
+ amount: params.amount,
508
+ type: _1.TxType.TxSent,
509
+ settled: false,
510
+ createdAt: Date.now(),
511
+ },
512
+ ]);
513
+ }
514
+ catch (e) {
515
+ console.warn("error saving offchain tx to repository", e);
516
+ }
517
+ finally {
518
+ return arkTxid;
519
+ }
433
520
  }
434
521
  async settle(params, eventCallback) {
435
522
  if (params?.inputs) {
@@ -702,7 +789,8 @@ class Wallet {
702
789
  (async () => {
703
790
  try {
704
791
  for await (const update of subscription) {
705
- if (update.newVtxos?.length > 0) {
792
+ if (update.newVtxos?.length > 0 ||
793
+ update.spentVtxos?.length > 0) {
706
794
  eventCallback({
707
795
  type: "vtxo",
708
796
  newVtxos: update.newVtxos.map((vtxo) => (0, utils_1.extendVirtualCoin)(this, vtxo)),
@@ -869,13 +957,12 @@ class Wallet {
869
957
  }
870
958
  }
871
959
  async makeRegisterIntentSignature(coins, outputs, onchainOutputsIndexes, cosignerPubKeys) {
872
- const nowSeconds = Math.floor(Date.now() / 1000);
873
960
  const inputs = this.prepareIntentProofInputs(coins);
874
961
  const message = {
875
962
  type: "register",
876
963
  onchain_output_indexes: onchainOutputsIndexes,
877
- valid_at: nowSeconds,
878
- expire_at: nowSeconds + 2 * 60, // valid for 2 minutes
964
+ valid_at: 0,
965
+ expire_at: 0,
879
966
  cosigners_public_keys: cosignerPubKeys,
880
967
  };
881
968
  const encodedMessage = JSON.stringify(message, null, 0);
@@ -887,11 +974,10 @@ class Wallet {
887
974
  };
888
975
  }
889
976
  async makeDeleteIntentSignature(coins) {
890
- const nowSeconds = Math.floor(Date.now() / 1000);
891
977
  const inputs = this.prepareIntentProofInputs(coins);
892
978
  const message = {
893
979
  type: "delete",
894
- expire_at: nowSeconds + 2 * 60, // valid for 2 minutes
980
+ expire_at: 0,
895
981
  };
896
982
  const encodedMessage = JSON.stringify(message, null, 0);
897
983
  const proof = intent_1.Intent.create(encodedMessage, inputs, []);
@@ -2,7 +2,7 @@ import { pubECDSA, pubSchnorr, randomPrivateKeyBytes, } from "@scure/btc-signer/
2
2
  import { SigHash } from "@scure/btc-signer";
3
3
  import { hex } from "@scure/base";
4
4
  import { TreeSignerSession } from '../tree/signingSession.js';
5
- import { schnorr, sign } from "@noble/secp256k1";
5
+ import { schnorr, signAsync } from "@noble/secp256k1";
6
6
  const ALL_SIGHASH = Object.values(SigHash).filter((x) => typeof x === "number");
7
7
  /**
8
8
  * In-memory single key implementation for Bitcoin transaction signing.
@@ -80,7 +80,7 @@ export class SingleKey {
80
80
  }
81
81
  async signMessage(message, signatureType = "schnorr") {
82
82
  if (signatureType === "ecdsa")
83
- return sign(message, this.key, { prehash: false });
84
- return schnorr.sign(message, this.key);
83
+ return signAsync(message, this.key, { prehash: false });
84
+ return schnorr.signAsync(message, this.key);
85
85
  }
86
86
  }
package/dist/esm/index.js CHANGED
@@ -18,7 +18,7 @@ import { Response } from './wallet/serviceWorker/response.js';
18
18
  import { ESPLORA_URL, EsploraProvider, } from './providers/onchain.js';
19
19
  import { RestArkProvider, SettlementEventType, } from './providers/ark.js';
20
20
  import { CLTVMultisigTapscript, ConditionCSVMultisigTapscript, ConditionMultisigTapscript, CSVMultisigTapscript, decodeTapscript, MultisigTapscript, } from './script/tapscript.js';
21
- import { hasBoardingTxExpired, buildOffchainTx, verifyTapscriptSignatures, } from './utils/arkTransaction.js';
21
+ import { hasBoardingTxExpired, buildOffchainTx, verifyTapscriptSignatures, combineTapscriptSigs, } from './utils/arkTransaction.js';
22
22
  import { VtxoTaprootTree, ConditionWitness, getArkPsbtFields, setArkPsbtField, ArkPsbtFieldKey, ArkPsbtFieldKeyType, CosignerPublicKey, VtxoTreeExpiry, } from './utils/unknownFields.js';
23
23
  import { Intent } from './intent/index.js';
24
24
  import { ArkNote } from './arknote/index.js';
@@ -45,7 +45,7 @@ decodeTapscript, MultisigTapscript, CSVMultisigTapscript, ConditionCSVMultisigTa
45
45
  // Ark PSBT fields
46
46
  ArkPsbtFieldKey, ArkPsbtFieldKeyType, setArkPsbtField, getArkPsbtFields, CosignerPublicKey, VtxoTreeExpiry, VtxoTaprootTree, ConditionWitness,
47
47
  // Utils
48
- buildOffchainTx, verifyTapscriptSignatures, waitForIncomingFunds, hasBoardingTxExpired,
48
+ buildOffchainTx, verifyTapscriptSignatures, waitForIncomingFunds, hasBoardingTxExpired, combineTapscriptSigs,
49
49
  // Arknote
50
50
  ArkNote,
51
51
  // Network
@@ -42,7 +42,14 @@ export class RestArkProvider {
42
42
  })) ?? [],
43
43
  digest: fromServer.digest ?? "",
44
44
  dust: BigInt(fromServer.dust ?? 0),
45
- fees: fromServer.fees,
45
+ fees: {
46
+ intentFee: {
47
+ ...fromServer.fees?.intentFee,
48
+ onchainInput: BigInt(fromServer.fees?.intentFee?.onchainInput ?? 0),
49
+ onchainOutput: BigInt(fromServer.fees?.intentFee?.onchainOutput ?? 0),
50
+ },
51
+ txFeeRate: fromServer?.fees?.txFeeRate ?? "",
52
+ },
46
53
  forfeitAddress: fromServer.forfeitAddress ?? "",
47
54
  forfeitPubkey: fromServer.forfeitPubkey ?? "",
48
55
  network: fromServer.network ?? "",
@@ -135,7 +142,7 @@ export class RestArkProvider {
135
142
  "Content-Type": "application/json",
136
143
  },
137
144
  body: JSON.stringify({
138
- proof: {
145
+ intent: {
139
146
  proof: intent.proof,
140
147
  message: intent.message,
141
148
  },
@@ -1,19 +1,15 @@
1
+ const getContractStorageKey = (id, key) => `contract:${id}:${key}`;
2
+ const getCollectionStorageKey = (type) => `collection:${type}`;
1
3
  export class ContractRepositoryImpl {
2
4
  constructor(storage) {
3
- this.cache = new Map();
4
5
  this.storage = storage;
5
6
  }
6
7
  async getContractData(contractId, key) {
7
- const storageKey = `contract:${contractId}:${key}`;
8
- const cached = this.cache.get(storageKey);
9
- if (cached !== undefined)
10
- return cached;
11
- const stored = await this.storage.getItem(storageKey);
8
+ const stored = await this.storage.getItem(getContractStorageKey(contractId, key));
12
9
  if (!stored)
13
10
  return null;
14
11
  try {
15
12
  const data = JSON.parse(stored);
16
- this.cache.set(storageKey, data);
17
13
  return data;
18
14
  }
19
15
  catch (error) {
@@ -22,49 +18,33 @@ export class ContractRepositoryImpl {
22
18
  }
23
19
  }
24
20
  async setContractData(contractId, key, data) {
25
- const storageKey = `contract:${contractId}:${key}`;
26
21
  try {
27
- // First persist to storage, only update cache if successful
28
- await this.storage.setItem(storageKey, JSON.stringify(data));
29
- this.cache.set(storageKey, data);
22
+ await this.storage.setItem(getContractStorageKey(contractId, key), JSON.stringify(data));
30
23
  }
31
24
  catch (error) {
32
- // Storage operation failed, cache remains unchanged
33
25
  console.error(`Failed to persist contract data for ${contractId}:${key}:`, error);
34
26
  throw error; // Rethrow to notify caller of failure
35
27
  }
36
28
  }
37
29
  async deleteContractData(contractId, key) {
38
- const storageKey = `contract:${contractId}:${key}`;
39
30
  try {
40
- // First remove from persistent storage, only delete from cache if successful
41
- await this.storage.removeItem(storageKey);
42
- this.cache.delete(storageKey);
31
+ await this.storage.removeItem(getContractStorageKey(contractId, key));
43
32
  }
44
33
  catch (error) {
45
- // Storage operation failed, cache remains unchanged
46
34
  console.error(`Failed to remove contract data for ${contractId}:${key}:`, error);
47
35
  throw error; // Rethrow to notify caller of failure
48
36
  }
49
37
  }
50
38
  async getContractCollection(contractType) {
51
- const storageKey = `collection:${contractType}`;
52
- const cached = this.cache.get(storageKey);
53
- if (cached !== undefined)
54
- return cached;
55
- const stored = await this.storage.getItem(storageKey);
56
- if (!stored) {
57
- this.cache.set(storageKey, []);
39
+ const stored = await this.storage.getItem(getCollectionStorageKey(contractType));
40
+ if (!stored)
58
41
  return [];
59
- }
60
42
  try {
61
43
  const collection = JSON.parse(stored);
62
- this.cache.set(storageKey, collection);
63
44
  return collection;
64
45
  }
65
46
  catch (error) {
66
47
  console.error(`Failed to parse contract collection ${contractType}:`, error);
67
- this.cache.set(storageKey, []);
68
48
  return [];
69
49
  }
70
50
  }
@@ -91,14 +71,10 @@ export class ContractRepositoryImpl {
91
71
  // Add new item
92
72
  newCollection = [...collection, item];
93
73
  }
94
- const storageKey = `collection:${contractType}`;
95
74
  try {
96
- // First persist to storage, only update cache if successful
97
- await this.storage.setItem(storageKey, JSON.stringify(newCollection));
98
- this.cache.set(storageKey, newCollection);
75
+ await this.storage.setItem(getCollectionStorageKey(contractType), JSON.stringify(newCollection));
99
76
  }
100
77
  catch (error) {
101
- // Storage operation failed, cache remains unchanged
102
78
  console.error(`Failed to persist contract collection ${contractType}:`, error);
103
79
  throw error; // Rethrow to notify caller of failure
104
80
  }
@@ -111,20 +87,15 @@ export class ContractRepositoryImpl {
111
87
  const collection = await this.getContractCollection(contractType);
112
88
  // Build new collection without the specified item
113
89
  const filtered = collection.filter((item) => item[idField] !== id);
114
- const storageKey = `collection:${contractType}`;
115
90
  try {
116
- // First persist to storage, only update cache if successful
117
- await this.storage.setItem(storageKey, JSON.stringify(filtered));
118
- this.cache.set(storageKey, filtered);
91
+ await this.storage.setItem(getCollectionStorageKey(contractType), JSON.stringify(filtered));
119
92
  }
120
93
  catch (error) {
121
- // Storage operation failed, cache remains unchanged
122
94
  console.error(`Failed to persist contract collection removal for ${contractType}:`, error);
123
95
  throw error; // Rethrow to notify caller of failure
124
96
  }
125
97
  }
126
98
  async clearContractData() {
127
99
  await this.storage.clear();
128
- this.cache.clear();
129
100
  }
130
101
  }
@@ -1,5 +1,9 @@
1
1
  import { hex } from "@scure/base";
2
2
  import { TaprootControlBlock } from "@scure/btc-signer";
3
+ const getVtxosStorageKey = (address) => `vtxos:${address}`;
4
+ const getUtxosStorageKey = (address) => `utxos:${address}`;
5
+ const getTransactionsStorageKey = (address) => `tx:${address}`;
6
+ const walletStateStorageKey = "wallet:state";
3
7
  // Utility functions for (de)serializing complex structures
4
8
  const toHex = (b) => (b ? hex.encode(b) : undefined);
5
9
  const fromHex = (h) => h ? hex.decode(h) : undefined;
@@ -44,33 +48,17 @@ const deserializeUtxo = (o) => ({
44
48
  export class WalletRepositoryImpl {
45
49
  constructor(storage) {
46
50
  this.storage = storage;
47
- this.cache = {
48
- vtxos: new Map(),
49
- utxos: new Map(),
50
- transactions: new Map(),
51
- walletState: null,
52
- initialized: new Set(),
53
- };
54
51
  }
55
52
  async getVtxos(address) {
56
- const cacheKey = `vtxos:${address}`;
57
- if (this.cache.vtxos.has(address)) {
58
- return this.cache.vtxos.get(address);
59
- }
60
- const stored = await this.storage.getItem(cacheKey);
61
- if (!stored) {
62
- this.cache.vtxos.set(address, []);
53
+ const stored = await this.storage.getItem(getVtxosStorageKey(address));
54
+ if (!stored)
63
55
  return [];
64
- }
65
56
  try {
66
57
  const parsed = JSON.parse(stored);
67
- const vtxos = parsed.map(deserializeVtxo);
68
- this.cache.vtxos.set(address, vtxos.slice());
69
- return vtxos.slice();
58
+ return parsed.map(deserializeVtxo);
70
59
  }
71
60
  catch (error) {
72
61
  console.error(`Failed to parse VTXOs for address ${address}:`, error);
73
- this.cache.vtxos.set(address, []);
74
62
  return [];
75
63
  }
76
64
  }
@@ -85,39 +73,27 @@ export class WalletRepositoryImpl {
85
73
  storedVtxos.push(vtxo);
86
74
  }
87
75
  }
88
- this.cache.vtxos.set(address, storedVtxos.slice());
89
- await this.storage.setItem(`vtxos:${address}`, JSON.stringify(storedVtxos.map(serializeVtxo)));
76
+ await this.storage.setItem(getVtxosStorageKey(address), JSON.stringify(storedVtxos.map(serializeVtxo)));
90
77
  }
91
78
  async removeVtxo(address, vtxoId) {
92
79
  const vtxos = await this.getVtxos(address);
93
80
  const [txid, vout] = vtxoId.split(":");
94
81
  const filtered = vtxos.filter((v) => !(v.txid === txid && v.vout === parseInt(vout, 10)));
95
- this.cache.vtxos.set(address, filtered.slice());
96
- await this.storage.setItem(`vtxos:${address}`, JSON.stringify(filtered.map(serializeVtxo)));
82
+ await this.storage.setItem(getVtxosStorageKey(address), JSON.stringify(filtered.map(serializeVtxo)));
97
83
  }
98
84
  async clearVtxos(address) {
99
- this.cache.vtxos.set(address, []);
100
- await this.storage.removeItem(`vtxos:${address}`);
85
+ await this.storage.removeItem(getVtxosStorageKey(address));
101
86
  }
102
87
  async getUtxos(address) {
103
- const cacheKey = `utxos:${address}`;
104
- if (this.cache.utxos.has(address)) {
105
- return this.cache.utxos.get(address);
106
- }
107
- const stored = await this.storage.getItem(cacheKey);
108
- if (!stored) {
109
- this.cache.utxos.set(address, []);
88
+ const stored = await this.storage.getItem(getUtxosStorageKey(address));
89
+ if (!stored)
110
90
  return [];
111
- }
112
91
  try {
113
92
  const parsed = JSON.parse(stored);
114
- const utxos = parsed.map(deserializeUtxo);
115
- this.cache.utxos.set(address, utxos.slice());
116
- return utxos.slice();
93
+ return parsed.map(deserializeUtxo);
117
94
  }
118
95
  catch (error) {
119
96
  console.error(`Failed to parse UTXOs for address ${address}:`, error);
120
- this.cache.utxos.set(address, []);
121
97
  return [];
122
98
  }
123
99
  }
@@ -132,38 +108,27 @@ export class WalletRepositoryImpl {
132
108
  storedUtxos.push(utxo);
133
109
  }
134
110
  });
135
- this.cache.utxos.set(address, storedUtxos.slice());
136
- await this.storage.setItem(`utxos:${address}`, JSON.stringify(storedUtxos.map(serializeUtxo)));
111
+ await this.storage.setItem(getUtxosStorageKey(address), JSON.stringify(storedUtxos.map(serializeUtxo)));
137
112
  }
138
113
  async removeUtxo(address, utxoId) {
139
114
  const utxos = await this.getUtxos(address);
140
115
  const [txid, vout] = utxoId.split(":");
141
116
  const filtered = utxos.filter((v) => !(v.txid === txid && v.vout === parseInt(vout, 10)));
142
- this.cache.utxos.set(address, filtered.slice());
143
- await this.storage.setItem(`utxos:${address}`, JSON.stringify(filtered.map(serializeUtxo)));
117
+ await this.storage.setItem(getUtxosStorageKey(address), JSON.stringify(filtered.map(serializeUtxo)));
144
118
  }
145
119
  async clearUtxos(address) {
146
- this.cache.utxos.set(address, []);
147
- await this.storage.removeItem(`utxos:${address}`);
120
+ await this.storage.removeItem(getUtxosStorageKey(address));
148
121
  }
149
122
  async getTransactionHistory(address) {
150
- const cacheKey = `tx:${address}`;
151
- if (this.cache.transactions.has(address)) {
152
- return this.cache.transactions.get(address);
153
- }
154
- const stored = await this.storage.getItem(cacheKey);
155
- if (!stored) {
156
- this.cache.transactions.set(address, []);
123
+ const storageKey = getTransactionsStorageKey(address);
124
+ const stored = await this.storage.getItem(storageKey);
125
+ if (!stored)
157
126
  return [];
158
- }
159
127
  try {
160
- const transactions = JSON.parse(stored);
161
- this.cache.transactions.set(address, transactions);
162
- return transactions.slice();
128
+ return JSON.parse(stored);
163
129
  }
164
130
  catch (error) {
165
131
  console.error(`Failed to parse transactions for address ${address}:`, error);
166
- this.cache.transactions.set(address, []);
167
132
  return [];
168
133
  }
169
134
  }
@@ -178,39 +143,25 @@ export class WalletRepositoryImpl {
178
143
  storedTransactions.push(tx);
179
144
  }
180
145
  }
181
- this.cache.transactions.set(address, storedTransactions);
182
- await this.storage.setItem(`tx:${address}`, JSON.stringify(storedTransactions));
146
+ await this.storage.setItem(getTransactionsStorageKey(address), JSON.stringify(storedTransactions));
183
147
  }
184
148
  async clearTransactions(address) {
185
- this.cache.transactions.set(address, []);
186
- await this.storage.removeItem(`tx:${address}`);
149
+ await this.storage.removeItem(getTransactionsStorageKey(address));
187
150
  }
188
151
  async getWalletState() {
189
- if (this.cache.walletState !== null ||
190
- this.cache.initialized.has("walletState")) {
191
- return this.cache.walletState;
192
- }
193
- const stored = await this.storage.getItem("wallet:state");
194
- if (!stored) {
195
- this.cache.walletState = null;
196
- this.cache.initialized.add("walletState");
152
+ const stored = await this.storage.getItem(walletStateStorageKey);
153
+ if (!stored)
197
154
  return null;
198
- }
199
155
  try {
200
156
  const state = JSON.parse(stored);
201
- this.cache.walletState = state;
202
- this.cache.initialized.add("walletState");
203
157
  return state;
204
158
  }
205
159
  catch (error) {
206
160
  console.error("Failed to parse wallet state:", error);
207
- this.cache.walletState = null;
208
- this.cache.initialized.add("walletState");
209
161
  return null;
210
162
  }
211
163
  }
212
164
  async saveWalletState(state) {
213
- this.cache.walletState = state;
214
- await this.storage.setItem("wallet:state", JSON.stringify(state));
165
+ await this.storage.setItem(walletStateStorageKey, JSON.stringify(state));
215
166
  }
216
167
  }
@@ -1,6 +1,6 @@
1
1
  import { schnorr } from "@noble/curves/secp256k1.js";
2
2
  import { hex } from "@scure/base";
3
- import { DEFAULT_SEQUENCE, SigHash } from "@scure/btc-signer";
3
+ import { DEFAULT_SEQUENCE, Script, SigHash } from "@scure/btc-signer";
4
4
  import { tapLeafHash } from "@scure/btc-signer/payment.js";
5
5
  import { CLTVMultisigTapscript, decodeTapscript, } from '../script/tapscript.js';
6
6
  import { scriptFromTapLeafScript, VtxoScript, } from '../script/base.js';
@@ -20,6 +20,17 @@ import { Transaction } from './transaction.js';
20
20
  * @returns Object containing the virtual transaction and checkpoint transactions
21
21
  */
22
22
  export function buildOffchainTx(inputs, outputs, serverUnrollScript) {
23
+ let hasOpReturn = false;
24
+ for (const [index, output] of outputs.entries()) {
25
+ if (!output.script)
26
+ throw new Error(`missing output script ${index}`);
27
+ const isOpReturn = Script.decode(output.script)[0] === "RETURN";
28
+ if (!isOpReturn)
29
+ continue;
30
+ if (hasOpReturn)
31
+ throw new Error("multiple OP_RETURN outputs");
32
+ hasOpReturn = true;
33
+ }
23
34
  const checkpoints = inputs.map((input) => buildCheckpointTx(input, serverUnrollScript));
24
35
  const arkTx = buildVirtualTx(checkpoints.map((c) => c.input), outputs);
25
36
  return {
@@ -204,3 +215,20 @@ export function verifyTapscriptSignatures(tx, inputIndex, requiredSigners, exclu
204
215
  throw new Error(`Missing signatures from: ${missingSigners.map((pk) => pk.slice(0, 16)).join(", ")}...`);
205
216
  }
206
217
  }
218
+ /**
219
+ * Merges the signed transaction with the original transaction
220
+ * @param signedTx signed transaction
221
+ * @param originalTx original transaction
222
+ */
223
+ export function combineTapscriptSigs(signedTx, originalTx) {
224
+ for (let i = 0; i < signedTx.inputsLength; i++) {
225
+ const input = originalTx.getInput(i);
226
+ const signedInput = signedTx.getInput(i);
227
+ if (!input.tapScriptSig)
228
+ throw new Error("No tapScriptSig");
229
+ originalTx.updateInput(i, {
230
+ tapScriptSig: input.tapScriptSig?.concat(signedInput.tapScriptSig),
231
+ });
232
+ }
233
+ return originalTx;
234
+ }
@@ -11,6 +11,10 @@ export function vtxosToTxs(spendable, spent, boardingBatchTxids) {
11
11
  // All vtxos are received unless:
12
12
  // - they resulted from a settlement (either boarding or refresh)
13
13
  // - they are the change of a spend tx
14
+ // - they were spent in a payment (have arkTxId set)
15
+ // - they resulted from a payment (their txid matches an arkTxId of a spent vtxo)
16
+ // First, collect all arkTxIds from spent vtxos to identify payment transactions
17
+ const paymentArkTxIds = new Set(spent.filter((v) => v.arkTxId).map((v) => v.arkTxId));
14
18
  let vtxosLeftToCheck = [...spent];
15
19
  for (const vtxo of [...spendable, ...spent]) {
16
20
  if (vtxo.virtualStatus.state !== "preconfirmed" &&
@@ -18,6 +22,16 @@ export function vtxosToTxs(spendable, spent, boardingBatchTxids) {
18
22
  vtxo.virtualStatus.commitmentTxIds.some((txid) => boardingBatchTxids.has(txid))) {
19
23
  continue;
20
24
  }
25
+ // Skip vtxos that were spent in a payment transaction
26
+ // These will be handled in the sent transaction section below
27
+ if (vtxo.arkTxId) {
28
+ continue;
29
+ }
30
+ // Skip vtxos that resulted from a payment transaction
31
+ // (their txid matches an arkTxId from a spent vtxo)
32
+ if (paymentArkTxIds.has(vtxo.txid)) {
33
+ continue;
34
+ }
21
35
  const settleVtxos = findVtxosSpentInSettlement(vtxosLeftToCheck, vtxo);
22
36
  vtxosLeftToCheck = removeVtxosFromList(vtxosLeftToCheck, settleVtxos);
23
37
  const settleAmount = reduceVtxosAmount(settleVtxos);
@@ -53,21 +67,17 @@ export function vtxosToTxs(spendable, spent, boardingBatchTxids) {
53
67
  // vtxos by settled by or ark txid
54
68
  const vtxosByTxid = new Map();
55
69
  for (const v of spent) {
56
- if (v.settledBy) {
57
- if (!vtxosByTxid.has(v.settledBy)) {
58
- vtxosByTxid.set(v.settledBy, []);
59
- }
60
- const currentVtxos = vtxosByTxid.get(v.settledBy);
61
- vtxosByTxid.set(v.settledBy, [...currentVtxos, v]);
62
- }
63
- if (!v.arkTxId) {
70
+ // Prefer arkTxId over settledBy to avoid duplicates
71
+ // A vtxo should only be grouped once
72
+ const groupKey = v.arkTxId || v.settledBy;
73
+ if (!groupKey) {
64
74
  continue;
65
75
  }
66
- if (!vtxosByTxid.has(v.arkTxId)) {
67
- vtxosByTxid.set(v.arkTxId, []);
76
+ if (!vtxosByTxid.has(groupKey)) {
77
+ vtxosByTxid.set(groupKey, []);
68
78
  }
69
- const currentVtxos = vtxosByTxid.get(v.arkTxId);
70
- vtxosByTxid.set(v.arkTxId, [...currentVtxos, v]);
79
+ const currentVtxos = vtxosByTxid.get(groupKey);
80
+ vtxosByTxid.set(groupKey, [...currentVtxos, v]);
71
81
  }
72
82
  for (const [sb, vtxos] of vtxosByTxid) {
73
83
  const resultedVtxos = findVtxosResultedFromTxid([...spendable, ...spent], sb);
@@ -82,7 +92,13 @@ export function vtxosToTxs(spendable, spent, boardingBatchTxids) {
82
92
  boardingTxid: "",
83
93
  arkTxid: "",
84
94
  };
85
- if (vtxo.virtualStatus.state === "preconfirmed") {
95
+ // Use the grouping key (sb) as arkTxid if it looks like an arkTxId
96
+ // (i.e., if the spent vtxos had arkTxId set, use that instead of result vtxo's txid)
97
+ const isArkTxId = vtxos.some((v) => v.arkTxId === sb);
98
+ if (isArkTxId) {
99
+ txKey.arkTxid = sb;
100
+ }
101
+ else if (vtxo.virtualStatus.state === "preconfirmed") {
86
102
  txKey.arkTxid = vtxo.txid;
87
103
  }
88
104
  txs.push({