@arkade-os/sdk 0.4.13 → 0.4.15

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.
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.timelockToSequence = timelockToSequence;
37
37
  exports.sequenceToTimelock = sequenceToTimelock;
38
38
  exports.resolveRole = resolveRole;
39
+ exports.isCltvSatisfied = isCltvSatisfied;
39
40
  exports.isCsvSpendable = isCsvSpendable;
40
41
  const bip68 = __importStar(require("bip68"));
41
42
  /**
@@ -78,6 +79,29 @@ function resolveRole(contract, context) {
78
79
  }
79
80
  return undefined;
80
81
  }
82
+ /**
83
+ * BIP65 threshold: locktime values below this are interpreted as block heights,
84
+ * values at or above are interpreted as Unix timestamps (seconds).
85
+ */
86
+ const CLTV_HEIGHT_THRESHOLD = 500000000n;
87
+ /**
88
+ * Check if an absolute (CLTV) locktime is currently satisfied.
89
+ *
90
+ * Following the BIP65 convention:
91
+ * - locktime < 500_000_000 → interpreted as a block height; compared against `context.blockHeight`
92
+ * - locktime >= 500_000_000 → interpreted as a Unix timestamp (seconds); compared against `context.currentTime`
93
+ *
94
+ * Returns false if the relevant context field is missing.
95
+ */
96
+ function isCltvSatisfied(context, locktime) {
97
+ if (locktime < CLTV_HEIGHT_THRESHOLD) {
98
+ if (context.blockHeight === undefined)
99
+ return false;
100
+ return BigInt(context.blockHeight) >= locktime;
101
+ }
102
+ const currentTimeSec = BigInt(Math.floor(context.currentTime / 1000));
103
+ return currentTimeSec >= locktime;
104
+ }
81
105
  /**
82
106
  * Check if a CSV timelock is currently satisfied for the given context/VTXO.
83
107
  */
@@ -59,7 +59,6 @@ exports.VHTLCContractHandler = {
59
59
  const role = (0, helpers_1.resolveRole)(contract, context);
60
60
  const preimage = contract.params?.preimage;
61
61
  const refundLocktime = BigInt(contract.params.refundLocktime);
62
- const currentTimeSec = Math.floor(context.currentTime / 1000);
63
62
  if (!role) {
64
63
  return null;
65
64
  }
@@ -70,7 +69,7 @@ exports.VHTLCContractHandler = {
70
69
  extraWitness: [base_1.hex.decode(preimage)],
71
70
  };
72
71
  }
73
- if (role === "sender" && BigInt(currentTimeSec) >= refundLocktime) {
72
+ if (role === "sender" && (0, helpers_1.isCltvSatisfied)(context, refundLocktime)) {
74
73
  return {
75
74
  leaf: script.refundWithoutReceiver(),
76
75
  };
@@ -154,7 +153,6 @@ exports.VHTLCContractHandler = {
154
153
  }
155
154
  const preimage = contract.params?.preimage;
156
155
  const refundLocktime = BigInt(contract.params.refundLocktime);
157
- const currentTimeSec = Math.floor(context.currentTime / 1000);
158
156
  if (context.collaborative) {
159
157
  if (role === "receiver" && preimage) {
160
158
  paths.push({
@@ -162,7 +160,7 @@ exports.VHTLCContractHandler = {
162
160
  extraWitness: [base_1.hex.decode(preimage)],
163
161
  });
164
162
  }
165
- if (role === "sender" && BigInt(currentTimeSec) >= refundLocktime) {
163
+ if (role === "sender" && (0, helpers_1.isCltvSatisfied)(context, refundLocktime)) {
166
164
  paths.push({
167
165
  leaf: script.refundWithoutReceiver(),
168
166
  });
@@ -14,5 +14,11 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.isBatchSignable = isBatchSignable;
18
+ /** Type guard for identities that support batch signing. */
19
+ function isBatchSignable(identity) {
20
+ return ("signMultiple" in identity &&
21
+ typeof identity.signMultiple === "function");
22
+ }
17
23
  __exportStar(require("./singleKey"), exports);
18
24
  __exportStar(require("./seedIdentity"), exports);
@@ -17,7 +17,7 @@ const ALL_SIGHASH = Object.values(btc_signer_1.SigHash).filter((x) => typeof x =
17
17
  function detectNetwork(descriptor) {
18
18
  return descriptor.includes("tpub") ? descriptors_scure_1.networks.testnet : descriptors_scure_1.networks.bitcoin;
19
19
  }
20
- function hasDescriptor(opts) {
20
+ function hasDescriptor(opts = {}) {
21
21
  return "descriptor" in opts && typeof opts.descriptor === "string";
22
22
  }
23
23
  /**
@@ -101,7 +101,7 @@ class SeedIdentity {
101
101
  * @param seed - 64-byte seed (typically from mnemonicToSeedSync)
102
102
  * @param opts - Network selection or custom descriptor.
103
103
  */
104
- static fromSeed(seed, opts) {
104
+ static fromSeed(seed, opts = {}) {
105
105
  const descriptor = hasDescriptor(opts)
106
106
  ? opts.descriptor
107
107
  : buildDescriptor(seed, opts.isMainnet ?? true);
@@ -185,7 +185,7 @@ class MnemonicIdentity extends SeedIdentity {
185
185
  * @param phrase - BIP39 mnemonic phrase (12 or 24 words)
186
186
  * @param opts - Network selection or custom descriptor, plus optional passphrase
187
187
  */
188
- static fromMnemonic(phrase, opts) {
188
+ static fromMnemonic(phrase, opts = {}) {
189
189
  if (!(0, bip39_1.validateMnemonic)(phrase, english_js_1.wordlist)) {
190
190
  throw new Error("Invalid mnemonic");
191
191
  }
package/dist/cjs/index.js CHANGED
@@ -36,9 +36,9 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  };
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.VtxoTreeExpiry = exports.CosignerPublicKey = exports.getArkPsbtFields = exports.setArkPsbtField = exports.ArkPsbtFieldKeyType = exports.ArkPsbtFieldKey = exports.TapTreeCoder = exports.CLTVMultisigTapscript = exports.ConditionMultisigTapscript = exports.ConditionCSVMultisigTapscript = exports.CSVMultisigTapscript = exports.MultisigTapscript = exports.decodeTapscript = exports.ServiceWorkerReadonlyWallet = exports.ServiceWorkerWallet = exports.ServiceWorkerTimeoutError = exports.MessageBusNotInitializedError = exports.MESSAGE_BUS_NOT_INITIALIZED = exports.DelegatorNotConfiguredError = exports.ReadonlyWalletError = exports.WalletNotInitializedError = exports.WalletMessageHandler = exports.MessageBus = exports.setupServiceWorker = exports.SettlementEventType = exports.ChainTxType = exports.IndexerTxType = exports.TxType = exports.VHTLC = exports.VtxoScript = exports.DelegateVtxo = exports.DefaultVtxo = exports.ArkAddress = exports.RestIndexerProvider = exports.RestArkProvider = exports.EsploraProvider = exports.ESPLORA_URL = exports.RestDelegatorProvider = exports.DelegatorManagerImpl = exports.VtxoManager = exports.Ramps = exports.OnchainWallet = exports.ReadonlyDescriptorIdentity = exports.MnemonicIdentity = exports.SeedIdentity = exports.ReadonlySingleKey = exports.SingleKey = exports.ReadonlyWallet = exports.Wallet = exports.asset = void 0;
40
- exports.contractFromArkContract = exports.decodeArkContract = exports.encodeArkContract = exports.VHTLCContractHandler = exports.DelegateContractHandler = exports.DefaultContractHandler = exports.contractHandlers = exports.ContractWatcher = exports.ContractManager = exports.getSequence = exports.isExpired = exports.isSubdust = exports.isSpendable = exports.isRecoverable = exports.buildForfeitTx = exports.validateConnectorsTxGraph = exports.validateVtxoTxGraph = exports.Batch = exports.maybeArkError = exports.ArkError = exports.Transaction = exports.Unroll = exports.P2A = exports.TxTree = exports.BIP322 = exports.Intent = exports.ContractRepositoryImpl = exports.WalletRepositoryImpl = exports.rollbackMigration = exports.getMigrationStatus = exports.requiresMigration = exports.migrateWalletRepository = exports.MIGRATION_KEY = exports.InMemoryContractRepository = exports.InMemoryWalletRepository = exports.IndexedDBContractRepository = exports.IndexedDBWalletRepository = exports.openDatabase = exports.closeDatabase = exports.networks = exports.ArkNote = exports.isValidArkAddress = exports.isVtxoExpiringSoon = exports.combineTapscriptSigs = exports.hasBoardingTxExpired = exports.waitForIncomingFunds = exports.verifyTapscriptSignatures = exports.buildOffchainTx = exports.ConditionWitness = exports.VtxoTaprootTree = void 0;
41
- exports.isArkContract = exports.contractFromArkContractWithAddress = void 0;
39
+ exports.getArkPsbtFields = exports.setArkPsbtField = exports.ArkPsbtFieldKeyType = exports.ArkPsbtFieldKey = exports.TapTreeCoder = exports.CLTVMultisigTapscript = exports.ConditionMultisigTapscript = exports.ConditionCSVMultisigTapscript = exports.CSVMultisigTapscript = exports.MultisigTapscript = exports.decodeTapscript = exports.DEFAULT_MESSAGE_TIMEOUTS = exports.ServiceWorkerReadonlyWallet = exports.ServiceWorkerWallet = exports.ServiceWorkerTimeoutError = exports.MessageBusNotInitializedError = exports.MESSAGE_BUS_NOT_INITIALIZED = exports.DelegatorNotConfiguredError = exports.ReadonlyWalletError = exports.WalletNotInitializedError = exports.WalletMessageHandler = exports.MessageBus = exports.setupServiceWorker = exports.SettlementEventType = exports.ChainTxType = exports.IndexerTxType = exports.TxType = exports.VHTLC = exports.VtxoScript = exports.DelegateVtxo = exports.DefaultVtxo = exports.ArkAddress = exports.RestIndexerProvider = exports.RestArkProvider = exports.EsploraProvider = exports.ESPLORA_URL = exports.RestDelegatorProvider = exports.DelegatorManagerImpl = exports.VtxoManager = exports.Ramps = exports.OnchainWallet = exports.isBatchSignable = exports.ReadonlyDescriptorIdentity = exports.MnemonicIdentity = exports.SeedIdentity = exports.ReadonlySingleKey = exports.SingleKey = exports.ReadonlyWallet = exports.Wallet = exports.asset = void 0;
40
+ exports.encodeArkContract = exports.VHTLCContractHandler = exports.DelegateContractHandler = exports.DefaultContractHandler = exports.contractHandlers = exports.ContractWatcher = exports.ContractManager = exports.getSequence = exports.isExpired = exports.isSubdust = exports.isSpendable = exports.isRecoverable = exports.buildForfeitTx = exports.validateConnectorsTxGraph = exports.validateVtxoTxGraph = exports.Batch = exports.maybeArkError = exports.ArkError = exports.Transaction = exports.Unroll = exports.P2A = exports.TxTree = exports.BIP322 = exports.Intent = exports.ContractRepositoryImpl = exports.WalletRepositoryImpl = exports.rollbackMigration = exports.getMigrationStatus = exports.requiresMigration = exports.migrateWalletRepository = exports.MIGRATION_KEY = exports.InMemoryContractRepository = exports.InMemoryWalletRepository = exports.IndexedDBContractRepository = exports.IndexedDBWalletRepository = exports.openDatabase = exports.closeDatabase = exports.networks = exports.ArkNote = exports.isValidArkAddress = exports.isVtxoExpiringSoon = exports.combineTapscriptSigs = exports.hasBoardingTxExpired = exports.waitForIncomingFunds = exports.verifyTapscriptSignatures = exports.buildOffchainTx = exports.ConditionWitness = exports.VtxoTaprootTree = exports.VtxoTreeExpiry = exports.CosignerPublicKey = void 0;
41
+ exports.isArkContract = exports.contractFromArkContractWithAddress = exports.contractFromArkContract = exports.decodeArkContract = void 0;
42
42
  const transaction_1 = require("./utils/transaction");
43
43
  Object.defineProperty(exports, "Transaction", { enumerable: true, get: function () { return transaction_1.Transaction; } });
44
44
  const singleKey_1 = require("./identity/singleKey");
@@ -48,6 +48,8 @@ const seedIdentity_1 = require("./identity/seedIdentity");
48
48
  Object.defineProperty(exports, "SeedIdentity", { enumerable: true, get: function () { return seedIdentity_1.SeedIdentity; } });
49
49
  Object.defineProperty(exports, "MnemonicIdentity", { enumerable: true, get: function () { return seedIdentity_1.MnemonicIdentity; } });
50
50
  Object.defineProperty(exports, "ReadonlyDescriptorIdentity", { enumerable: true, get: function () { return seedIdentity_1.ReadonlyDescriptorIdentity; } });
51
+ const identity_1 = require("./identity");
52
+ Object.defineProperty(exports, "isBatchSignable", { enumerable: true, get: function () { return identity_1.isBatchSignable; } });
51
53
  const address_1 = require("./script/address");
52
54
  Object.defineProperty(exports, "ArkAddress", { enumerable: true, get: function () { return address_1.ArkAddress; } });
53
55
  const vhtlc_1 = require("./script/vhtlc");
@@ -84,6 +86,7 @@ Object.defineProperty(exports, "VtxoManager", { enumerable: true, get: function
84
86
  const wallet_3 = require("./wallet/serviceWorker/wallet");
85
87
  Object.defineProperty(exports, "ServiceWorkerWallet", { enumerable: true, get: function () { return wallet_3.ServiceWorkerWallet; } });
86
88
  Object.defineProperty(exports, "ServiceWorkerReadonlyWallet", { enumerable: true, get: function () { return wallet_3.ServiceWorkerReadonlyWallet; } });
89
+ Object.defineProperty(exports, "DEFAULT_MESSAGE_TIMEOUTS", { enumerable: true, get: function () { return wallet_3.DEFAULT_MESSAGE_TIMEOUTS; } });
87
90
  const onchain_1 = require("./wallet/onchain");
88
91
  Object.defineProperty(exports, "OnchainWallet", { enumerable: true, get: function () { return onchain_1.OnchainWallet; } });
89
92
  const utils_1 = require("./worker/browser/utils");
@@ -535,7 +535,7 @@ var CLTVMultisigTapscript;
535
535
  throw new Error(`Invalid script: too short (expected at least 3)`);
536
536
  }
537
537
  const locktime = asm[0];
538
- if (typeof locktime === "string" || typeof locktime === "number") {
538
+ if (typeof locktime === "string") {
539
539
  throw new Error("Invalid script: expected locktime number");
540
540
  }
541
541
  if (asm[1] !== "CHECKLOCKTIMEVERIFY" || asm[2] !== "DROP") {
@@ -549,7 +549,13 @@ var CLTVMultisigTapscript;
549
549
  catch (error) {
550
550
  throw new Error(`Invalid multisig script: ${error instanceof Error ? error.message : String(error)}`);
551
551
  }
552
- const absoluteTimelock = MinimalScriptNum.decode(locktime);
552
+ let absoluteTimelock;
553
+ if (typeof locktime === "number") {
554
+ absoluteTimelock = BigInt(locktime);
555
+ }
556
+ else {
557
+ absoluteTimelock = MinimalScriptNum.decode(locktime);
558
+ }
553
559
  const reconstructed = encode({
554
560
  absoluteTimelock,
555
561
  ...multisig.params,
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ServiceWorkerWallet = exports.ServiceWorkerReadonlyWallet = void 0;
3
+ exports.ServiceWorkerWallet = exports.ServiceWorkerReadonlyWallet = exports.DEFAULT_MESSAGE_TIMEOUTS = void 0;
4
4
  const base_1 = require("@scure/base");
5
5
  const utils_1 = require("../../worker/browser/utils");
6
6
  const repositories_1 = require("../../repositories");
@@ -14,6 +14,47 @@ function isMessageBusNotInitializedError(error) {
14
14
  return (error instanceof Error &&
15
15
  error.message.includes(errors_1.MESSAGE_BUS_NOT_INITIALIZED));
16
16
  }
17
+ exports.DEFAULT_MESSAGE_TIMEOUTS = {
18
+ // Fast reads — fail quickly
19
+ GET_ADDRESS: 10000,
20
+ GET_BALANCE: 10000,
21
+ GET_BOARDING_ADDRESS: 10000,
22
+ GET_STATUS: 10000,
23
+ GET_DELEGATE_INFO: 10000,
24
+ IS_CONTRACT_MANAGER_WATCHING: 10000,
25
+ // Medium reads — may involve indexer queries
26
+ GET_VTXOS: 20000,
27
+ GET_BOARDING_UTXOS: 20000,
28
+ GET_TRANSACTION_HISTORY: 20000,
29
+ GET_CONTRACTS: 20000,
30
+ GET_CONTRACTS_WITH_VTXOS: 20000,
31
+ GET_SPENDABLE_PATHS: 20000,
32
+ GET_ALL_SPENDING_PATHS: 20000,
33
+ GET_ASSET_DETAILS: 20000,
34
+ GET_EXPIRING_VTXOS: 20000,
35
+ GET_EXPIRED_BOARDING_UTXOS: 20000,
36
+ GET_RECOVERABLE_BALANCE: 20000,
37
+ RELOAD_WALLET: 20000,
38
+ // Transactions — need more headroom
39
+ SEND_BITCOIN: 50000,
40
+ SEND: 50000,
41
+ SETTLE: 50000,
42
+ ISSUE: 50000,
43
+ REISSUE: 50000,
44
+ BURN: 50000,
45
+ DELEGATE: 50000,
46
+ RECOVER_VTXOS: 50000,
47
+ RENEW_VTXOS: 50000,
48
+ SWEEP_EXPIRED_BOARDING_UTXOS: 50000,
49
+ // Misc writes
50
+ INIT_WALLET: 30000,
51
+ CLEAR: 10000,
52
+ SIGN_TRANSACTION: 30000,
53
+ CREATE_CONTRACT: 30000,
54
+ UPDATE_CONTRACT: 30000,
55
+ DELETE_CONTRACT: 10000,
56
+ REFRESH_VTXOS: 30000,
57
+ };
17
58
  const DEDUPABLE_REQUEST_TYPES = new Set([
18
59
  "GET_ADDRESS",
19
60
  "GET_BALANCE",
@@ -129,6 +170,7 @@ class ServiceWorkerReadonlyWallet {
129
170
  this.messageTag = messageTag;
130
171
  this.initConfig = null;
131
172
  this.initWalletPayload = null;
173
+ this.messageTimeouts = exports.DEFAULT_MESSAGE_TIMEOUTS;
132
174
  this.reinitPromise = null;
133
175
  this.pingPromise = null;
134
176
  this.inflightRequests = new Map();
@@ -137,6 +179,9 @@ class ServiceWorkerReadonlyWallet {
137
179
  this.contractRepository = contractRepository;
138
180
  this._readonlyAssetManager = new ServiceWorkerReadonlyAssetManager((msg) => this.sendMessage(msg), messageTag);
139
181
  }
182
+ getTimeoutForRequest(request) {
183
+ return this.messageTimeouts[request.type] ?? 30000;
184
+ }
140
185
  static async create(options) {
141
186
  const walletRepository = options.storage?.walletRepository ??
142
187
  new repositories_1.IndexedDBWalletRepository();
@@ -188,6 +233,12 @@ class ServiceWorkerReadonlyWallet {
188
233
  };
189
234
  wallet.initWalletPayload = initConfig;
190
235
  wallet.messageBusTimeoutMs = options.messageBusTimeoutMs;
236
+ if (options.messageTimeouts) {
237
+ wallet.messageTimeouts = {
238
+ ...exports.DEFAULT_MESSAGE_TIMEOUTS,
239
+ ...options.messageTimeouts,
240
+ };
241
+ }
191
242
  return wallet;
192
243
  }
193
244
  /**
@@ -223,7 +274,7 @@ class ServiceWorkerReadonlyWallet {
223
274
  serviceWorker,
224
275
  });
225
276
  }
226
- sendMessageDirect(request) {
277
+ sendMessageDirect(request, timeoutMs) {
227
278
  return new Promise((resolve, reject) => {
228
279
  const cleanup = () => {
229
280
  clearTimeout(timeoutId);
@@ -232,7 +283,7 @@ class ServiceWorkerReadonlyWallet {
232
283
  const timeoutId = setTimeout(() => {
233
284
  cleanup();
234
285
  reject(new errors_1.ServiceWorkerTimeoutError(`Service worker message timed out (${request.type})`));
235
- }, 30000);
286
+ }, timeoutMs);
236
287
  const messageHandler = (event) => {
237
288
  const response = event.data;
238
289
  if (request.id !== response.id) {
@@ -255,14 +306,14 @@ class ServiceWorkerReadonlyWallet {
255
306
  // first response for which isComplete returns true. The timeout resets
256
307
  // on every intermediate event so long-running but progressing operations
257
308
  // don't time out prematurely.
258
- sendMessageStreaming(request, onEvent, isComplete) {
309
+ sendMessageStreaming(request, onEvent, isComplete, timeoutMs) {
259
310
  return new Promise((resolve, reject) => {
260
311
  const resetTimeout = () => {
261
312
  clearTimeout(timeoutId);
262
313
  timeoutId = setTimeout(() => {
263
314
  cleanup();
264
315
  reject(new errors_1.ServiceWorkerTimeoutError(`Service worker message timed out (${request.type})`));
265
- }, 30000);
316
+ }, timeoutMs);
266
317
  };
267
318
  const cleanup = () => {
268
319
  clearTimeout(timeoutId);
@@ -348,10 +399,11 @@ class ServiceWorkerReadonlyWallet {
348
399
  await this.reinitialize();
349
400
  }
350
401
  }
402
+ const timeoutMs = this.getTimeoutForRequest(request);
351
403
  const maxRetries = 2;
352
404
  for (let attempt = 0;; attempt++) {
353
405
  try {
354
- return await this.sendMessageDirect(request);
406
+ return await this.sendMessageDirect(request, timeoutMs);
355
407
  }
356
408
  catch (error) {
357
409
  if (!isMessageBusNotInitializedError(error) ||
@@ -373,10 +425,11 @@ class ServiceWorkerReadonlyWallet {
373
425
  await this.reinitialize();
374
426
  }
375
427
  }
428
+ const timeoutMs = this.getTimeoutForRequest(request);
376
429
  const maxRetries = 2;
377
430
  for (let attempt = 0;; attempt++) {
378
431
  try {
379
- return await this.sendMessageStreaming(request, onEvent, isComplete);
432
+ return await this.sendMessageStreaming(request, onEvent, isComplete, timeoutMs);
380
433
  }
381
434
  catch (error) {
382
435
  if (!isMessageBusNotInitializedError(error) ||
@@ -401,7 +454,7 @@ class ServiceWorkerReadonlyWallet {
401
454
  id: (0, utils_2.getRandomId)(),
402
455
  payload: this.initWalletPayload,
403
456
  };
404
- await this.sendMessageDirect(initMessage);
457
+ await this.sendMessageDirect(initMessage, this.getTimeoutForRequest(initMessage));
405
458
  })().finally(() => {
406
459
  this.reinitPromise = null;
407
460
  });
@@ -793,6 +846,12 @@ class ServiceWorkerWallet extends ServiceWorkerReadonlyWallet {
793
846
  };
794
847
  wallet.initWalletPayload = initConfig;
795
848
  wallet.messageBusTimeoutMs = options.messageBusTimeoutMs;
849
+ if (options.messageTimeouts) {
850
+ wallet.messageTimeouts = {
851
+ ...exports.DEFAULT_MESSAGE_TIMEOUTS,
852
+ ...options.messageTimeouts,
853
+ };
854
+ }
796
855
  return wallet;
797
856
  }
798
857
  /**
@@ -581,6 +581,7 @@ class VtxoManager {
581
581
  return;
582
582
  }
583
583
  if (e.message.includes("VTXO_ALREADY_REGISTERED") ||
584
+ e.message.includes("VTXO_ALREADY_SPENT") ||
584
585
  e.message.includes("duplicated input")) {
585
586
  // VTXO is already being used in a concurrent
586
587
  // user-initiated operation. Skip silently — the
@@ -15,6 +15,7 @@ const ark_1 = require("../providers/ark");
15
15
  const forfeit_1 = require("../forfeit");
16
16
  const validation_1 = require("../tree/validation");
17
17
  const validation_2 = require("./validation");
18
+ const identity_1 = require("../identity");
18
19
  const _1 = require(".");
19
20
  const asset_1 = require("./asset");
20
21
  const base_2 = require("../script/base");
@@ -345,6 +346,19 @@ class ReadonlyWallet {
345
346
  }
346
347
  const requestStartedAt = Date.now();
347
348
  const allVtxos = [];
349
+ const extendWithScript = (vtxo) => {
350
+ const vtxoScript = vtxo.script
351
+ ? scriptMap.get(vtxo.script)
352
+ : undefined;
353
+ if (!vtxoScript)
354
+ return undefined;
355
+ return {
356
+ ...vtxo,
357
+ forfeitTapLeafScript: vtxoScript.forfeit(),
358
+ intentTapLeafScript: vtxoScript.forfeit(),
359
+ tapTree: vtxoScript.encode(),
360
+ };
361
+ };
348
362
  // Full fetch for scripts with no cursor.
349
363
  if (bootstrapScripts.length > 0) {
350
364
  const response = await this.indexerProvider.getVtxos({
@@ -369,46 +383,76 @@ class ReadonlyWallet {
369
383
  // Extend every fetched VTXO and upsert into the cache.
370
384
  const fetchedExtended = [];
371
385
  for (const vtxo of allVtxos) {
372
- const vtxoScript = vtxo.script
373
- ? scriptMap.get(vtxo.script)
374
- : undefined;
375
- if (!vtxoScript)
376
- continue;
377
- fetchedExtended.push({
378
- ...vtxo,
379
- forfeitTapLeafScript: vtxoScript.forfeit(),
380
- intentTapLeafScript: vtxoScript.forfeit(),
381
- tapTree: vtxoScript.encode(),
382
- });
386
+ const extended = extendWithScript(vtxo);
387
+ if (extended)
388
+ fetchedExtended.push(extended);
383
389
  }
384
390
  // Save VTXOs first, then advance cursors only on success.
385
391
  const cutoff = (0, syncCursors_1.cursorCutoff)(requestStartedAt);
386
392
  await this.walletRepository.saveVtxos(address, fetchedExtended);
387
393
  await (0, syncCursors_1.advanceSyncCursors)(this.walletRepository, Object.fromEntries(allScripts.map((s) => [s, cutoff])));
388
- // For delta syncs, reconcile pending (preconfirmed/spent) VTXOs
389
- // whose state may have changed since the cursor so that
390
- // getVtxos()/getTransactionHistory() don't serve stale state.
394
+ // Delta-sync reconciliation: full re-fetch for delta scripts.
395
+ //
396
+ // The delta fetch (above) only returns VTXOs changed after the
397
+ // cursor, so it can miss preconfirmed VTXOs that were consumed
398
+ // by a round between syncs. Rather than layering targeted
399
+ // queries (pendingOnly, spendableOnly) with pagination guards
400
+ // and set algebra, we perform a single unfiltered re-fetch for
401
+ // delta scripts. This is slightly more data over the wire but
402
+ // gives us complete, authoritative state in one call and keeps
403
+ // the reconciliation logic simple.
404
+ //
405
+ // Any cached non-spent VTXO that is absent from the full
406
+ // result set is marked spent; any VTXO whose state changed
407
+ // (e.g. preconfirmed → settled) is updated in place.
391
408
  if (hasDelta) {
392
- const { vtxos: pendingVtxos } = await this.indexerProvider.getVtxos({
409
+ const { vtxos: fullVtxos, page: fullPage } = await this.indexerProvider.getVtxos({
393
410
  scripts: deltaScripts,
394
- pendingOnly: true,
395
411
  });
396
- const pendingExtended = [];
397
- for (const vtxo of pendingVtxos) {
398
- const vtxoScript = vtxo.script
399
- ? scriptMap.get(vtxo.script)
400
- : undefined;
401
- if (!vtxoScript)
402
- continue;
403
- pendingExtended.push({
404
- ...vtxo,
405
- forfeitTapLeafScript: vtxoScript.forfeit(),
406
- intentTapLeafScript: vtxoScript.forfeit(),
407
- tapTree: vtxoScript.encode(),
408
- });
412
+ // Reconciliation is best-effort: if the response is
413
+ // paginated we don't have a complete picture, so we skip
414
+ // rather than act on partial data. Wallets with enough
415
+ // VTXOs to exceed a single page rely solely on the
416
+ // cursor-based delta mechanism for state updates.
417
+ const fullSetComplete = !fullPage || fullPage.total <= 1;
418
+ if (fullSetComplete) {
419
+ const fullOutpoints = new Map(fullVtxos.map((v) => [`${v.txid}:${v.vout}`, v]));
420
+ const deltaScriptSet = new Set(deltaScripts);
421
+ const cachedVtxos = await this.walletRepository.getVtxos(address);
422
+ const reconciledExtended = [];
423
+ for (const cached of cachedVtxos) {
424
+ if (!cached.script ||
425
+ !deltaScriptSet.has(cached.script) ||
426
+ cached.isSpent) {
427
+ continue;
428
+ }
429
+ const outpoint = `${cached.txid}:${cached.vout}`;
430
+ const fresh = fullOutpoints.get(outpoint);
431
+ if (!fresh) {
432
+ // Server no longer knows about this VTXO —
433
+ // it was spent between syncs.
434
+ reconciledExtended.push({
435
+ ...cached,
436
+ isSpent: true,
437
+ });
438
+ continue;
439
+ }
440
+ const extended = extendWithScript(fresh);
441
+ if (extended &&
442
+ extended.virtualStatus.state !==
443
+ cached.virtualStatus.state) {
444
+ // State transitioned (e.g. preconfirmed →
445
+ // settled) — update the cached entry.
446
+ reconciledExtended.push(extended);
447
+ }
448
+ }
449
+ if (reconciledExtended.length > 0) {
450
+ console.warn(`[ark-sdk] delta sync: reconciled ${reconciledExtended.length} stale VTXO(s) via full re-fetch`);
451
+ await this.walletRepository.saveVtxos(address, reconciledExtended);
452
+ }
409
453
  }
410
- if (pendingExtended.length > 0) {
411
- await this.walletRepository.saveVtxos(address, pendingExtended);
454
+ else {
455
+ console.warn("[ark-sdk] delta sync: skipping reconciliation — full re-fetch was paginated");
412
456
  }
413
457
  }
414
458
  return {
@@ -1719,16 +1763,47 @@ class Wallet extends ReadonlyWallet {
1719
1763
  tapLeafScript: input.forfeitTapLeafScript,
1720
1764
  };
1721
1765
  }), outputs, this.serverUnrollScript);
1722
- const signedVirtualTx = await this.identity.sign(offchainTx.arkTx);
1766
+ let signedVirtualTx;
1767
+ let userSignedCheckpoints;
1768
+ if ((0, identity_1.isBatchSignable)(this.identity)) {
1769
+ // Batch-sign arkTx + all checkpoints in one wallet popup.
1770
+ // Clone so the provider can't mutate originals before submitTx.
1771
+ const requests = [
1772
+ { tx: offchainTx.arkTx.clone() },
1773
+ ...offchainTx.checkpoints.map((c) => ({ tx: c.clone() })),
1774
+ ];
1775
+ const signed = await this.identity.signMultiple(requests);
1776
+ if (signed.length !== requests.length) {
1777
+ throw new Error(`signMultiple returned ${signed.length} transactions, expected ${requests.length}`);
1778
+ }
1779
+ const [firstSignedTx, ...signedCheckpoints] = signed;
1780
+ signedVirtualTx = firstSignedTx;
1781
+ userSignedCheckpoints = signedCheckpoints;
1782
+ }
1783
+ else {
1784
+ signedVirtualTx = await this.identity.sign(offchainTx.arkTx);
1785
+ }
1723
1786
  // Mark pending before submitting — if we crash between submit and
1724
1787
  // finalize, the next init will recover via finalizePendingTxs.
1725
1788
  await this.setPendingTxFlag(true);
1726
1789
  const { arkTxid, signedCheckpointTxs } = await this.arkProvider.submitTx(base_1.base64.encode(signedVirtualTx.toPSBT()), offchainTx.checkpoints.map((c) => base_1.base64.encode(c.toPSBT())));
1727
- const finalCheckpoints = await Promise.all(signedCheckpointTxs.map(async (c) => {
1728
- const tx = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(c));
1729
- const signedCheckpoint = await this.identity.sign(tx);
1730
- return base_1.base64.encode(signedCheckpoint.toPSBT());
1731
- }));
1790
+ let finalCheckpoints;
1791
+ if (userSignedCheckpoints) {
1792
+ // Merge pre-signed user signatures onto server-signed checkpoints
1793
+ finalCheckpoints = signedCheckpointTxs.map((c, i) => {
1794
+ const serverSigned = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(c));
1795
+ (0, arkTransaction_1.combineTapscriptSigs)(userSignedCheckpoints[i], serverSigned);
1796
+ return base_1.base64.encode(serverSigned.toPSBT());
1797
+ });
1798
+ }
1799
+ else {
1800
+ // Legacy: sign each checkpoint individually (N popups)
1801
+ finalCheckpoints = await Promise.all(signedCheckpointTxs.map(async (c) => {
1802
+ const tx = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(c));
1803
+ const signedCheckpoint = await this.identity.sign(tx);
1804
+ return base_1.base64.encode(signedCheckpoint.toPSBT());
1805
+ }));
1806
+ }
1732
1807
  await this.arkProvider.finalizeTx(arkTxid, finalCheckpoints);
1733
1808
  try {
1734
1809
  await this.setPendingTxFlag(false);
@@ -39,6 +39,29 @@ export function resolveRole(contract, context) {
39
39
  }
40
40
  return undefined;
41
41
  }
42
+ /**
43
+ * BIP65 threshold: locktime values below this are interpreted as block heights,
44
+ * values at or above are interpreted as Unix timestamps (seconds).
45
+ */
46
+ const CLTV_HEIGHT_THRESHOLD = 500000000n;
47
+ /**
48
+ * Check if an absolute (CLTV) locktime is currently satisfied.
49
+ *
50
+ * Following the BIP65 convention:
51
+ * - locktime < 500_000_000 → interpreted as a block height; compared against `context.blockHeight`
52
+ * - locktime >= 500_000_000 → interpreted as a Unix timestamp (seconds); compared against `context.currentTime`
53
+ *
54
+ * Returns false if the relevant context field is missing.
55
+ */
56
+ export function isCltvSatisfied(context, locktime) {
57
+ if (locktime < CLTV_HEIGHT_THRESHOLD) {
58
+ if (context.blockHeight === undefined)
59
+ return false;
60
+ return BigInt(context.blockHeight) >= locktime;
61
+ }
62
+ const currentTimeSec = BigInt(Math.floor(context.currentTime / 1000));
63
+ return currentTimeSec >= locktime;
64
+ }
42
65
  /**
43
66
  * Check if a CSV timelock is currently satisfied for the given context/VTXO.
44
67
  */
@@ -1,6 +1,6 @@
1
1
  import { hex } from "@scure/base";
2
2
  import { VHTLC } from '../../script/vhtlc.js';
3
- import { isCsvSpendable, resolveRole, sequenceToTimelock, timelockToSequence, } from './helpers.js';
3
+ import { isCltvSatisfied, isCsvSpendable, resolveRole, sequenceToTimelock, timelockToSequence, } from './helpers.js';
4
4
  /**
5
5
  * Handler for Virtual Hash Time Lock Contract (VHTLC).
6
6
  *
@@ -56,7 +56,6 @@ export const VHTLCContractHandler = {
56
56
  const role = resolveRole(contract, context);
57
57
  const preimage = contract.params?.preimage;
58
58
  const refundLocktime = BigInt(contract.params.refundLocktime);
59
- const currentTimeSec = Math.floor(context.currentTime / 1000);
60
59
  if (!role) {
61
60
  return null;
62
61
  }
@@ -67,7 +66,7 @@ export const VHTLCContractHandler = {
67
66
  extraWitness: [hex.decode(preimage)],
68
67
  };
69
68
  }
70
- if (role === "sender" && BigInt(currentTimeSec) >= refundLocktime) {
69
+ if (role === "sender" && isCltvSatisfied(context, refundLocktime)) {
71
70
  return {
72
71
  leaf: script.refundWithoutReceiver(),
73
72
  };
@@ -151,7 +150,6 @@ export const VHTLCContractHandler = {
151
150
  }
152
151
  const preimage = contract.params?.preimage;
153
152
  const refundLocktime = BigInt(contract.params.refundLocktime);
154
- const currentTimeSec = Math.floor(context.currentTime / 1000);
155
153
  if (context.collaborative) {
156
154
  if (role === "receiver" && preimage) {
157
155
  paths.push({
@@ -159,7 +157,7 @@ export const VHTLCContractHandler = {
159
157
  extraWitness: [hex.decode(preimage)],
160
158
  });
161
159
  }
162
- if (role === "sender" && BigInt(currentTimeSec) >= refundLocktime) {
160
+ if (role === "sender" && isCltvSatisfied(context, refundLocktime)) {
163
161
  paths.push({
164
162
  leaf: script.refundWithoutReceiver(),
165
163
  });
@@ -1,2 +1,7 @@
1
+ /** Type guard for identities that support batch signing. */
2
+ export function isBatchSignable(identity) {
3
+ return ("signMultiple" in identity &&
4
+ typeof identity.signMultiple === "function");
5
+ }
1
6
  export * from './singleKey.js';
2
7
  export * from './seedIdentity.js';
@@ -14,7 +14,7 @@ const ALL_SIGHASH = Object.values(SigHash).filter((x) => typeof x === "number");
14
14
  function detectNetwork(descriptor) {
15
15
  return descriptor.includes("tpub") ? networks.testnet : networks.bitcoin;
16
16
  }
17
- function hasDescriptor(opts) {
17
+ function hasDescriptor(opts = {}) {
18
18
  return "descriptor" in opts && typeof opts.descriptor === "string";
19
19
  }
20
20
  /**
@@ -98,7 +98,7 @@ export class SeedIdentity {
98
98
  * @param seed - 64-byte seed (typically from mnemonicToSeedSync)
99
99
  * @param opts - Network selection or custom descriptor.
100
100
  */
101
- static fromSeed(seed, opts) {
101
+ static fromSeed(seed, opts = {}) {
102
102
  const descriptor = hasDescriptor(opts)
103
103
  ? opts.descriptor
104
104
  : buildDescriptor(seed, opts.isMainnet ?? true);
@@ -181,7 +181,7 @@ export class MnemonicIdentity extends SeedIdentity {
181
181
  * @param phrase - BIP39 mnemonic phrase (12 or 24 words)
182
182
  * @param opts - Network selection or custom descriptor, plus optional passphrase
183
183
  */
184
- static fromMnemonic(phrase, opts) {
184
+ static fromMnemonic(phrase, opts = {}) {
185
185
  if (!validateMnemonic(phrase, wordlist)) {
186
186
  throw new Error("Invalid mnemonic");
187
187
  }