@arkade-os/sdk 0.4.24 → 0.4.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/cjs/contracts/contractManager.js +44 -8
  2. package/dist/cjs/contracts/contractWatcher.js +2 -2
  3. package/dist/cjs/contracts/vtxoOwnership.js +18 -0
  4. package/dist/cjs/repositories/inMemory/walletRepository.js +35 -0
  5. package/dist/cjs/repositories/indexedDB/walletRepository.js +117 -0
  6. package/dist/cjs/repositories/realm/walletRepository.js +28 -0
  7. package/dist/cjs/repositories/sqlite/walletRepository.js +23 -0
  8. package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +14 -3
  9. package/dist/cjs/wallet/serviceWorker/wallet.js +10 -0
  10. package/dist/cjs/wallet/vtxo-manager.js +112 -16
  11. package/dist/cjs/wallet/wallet.js +3 -17
  12. package/dist/cjs/worker/expo/processors/contractPollProcessor.js +1 -1
  13. package/dist/esm/contracts/contractManager.js +45 -9
  14. package/dist/esm/contracts/contractWatcher.js +3 -3
  15. package/dist/esm/contracts/vtxoOwnership.js +16 -0
  16. package/dist/esm/repositories/inMemory/walletRepository.js +35 -0
  17. package/dist/esm/repositories/indexedDB/walletRepository.js +117 -0
  18. package/dist/esm/repositories/realm/walletRepository.js +28 -0
  19. package/dist/esm/repositories/sqlite/walletRepository.js +23 -0
  20. package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +15 -4
  21. package/dist/esm/wallet/serviceWorker/wallet.js +10 -0
  22. package/dist/esm/wallet/vtxo-manager.js +112 -16
  23. package/dist/esm/wallet/wallet.js +4 -18
  24. package/dist/esm/worker/expo/processors/contractPollProcessor.js +2 -2
  25. package/dist/types/contracts/contractManager.d.ts +17 -1
  26. package/dist/types/contracts/vtxoOwnership.d.ts +9 -1
  27. package/dist/types/repositories/inMemory/walletRepository.d.ts +4 -1
  28. package/dist/types/repositories/indexedDB/walletRepository.d.ts +4 -1
  29. package/dist/types/repositories/realm/walletRepository.d.ts +4 -1
  30. package/dist/types/repositories/sqlite/walletRepository.d.ts +4 -1
  31. package/dist/types/repositories/walletRepository.d.ts +21 -0
  32. package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +14 -2
  33. package/dist/types/wallet/vtxo-manager.d.ts +32 -5
  34. package/package.json +1 -1
@@ -373,6 +373,46 @@ class ContractManager {
373
373
  : undefined,
374
374
  });
375
375
  }
376
+ async refreshOutpoints(outpoints) {
377
+ if (outpoints.length === 0)
378
+ return;
379
+ const { vtxos } = await this.config.indexerProvider.getVtxos({
380
+ outpoints,
381
+ });
382
+ if (vtxos.length === 0)
383
+ return;
384
+ // Filter to outputs whose script we own. Map them to their owning
385
+ // contract so we can write through to the right per-address entry
386
+ // in the wallet repository.
387
+ const scripts = Array.from(new Set(vtxos.map((v) => v.script)));
388
+ const contracts = await this.config.contractRepository.getContracts({
389
+ script: scripts,
390
+ });
391
+ const scriptToContract = new Map(contracts.map((c) => [c.script, c]));
392
+ const owned = vtxos.filter((v) => scriptToContract.has(v.script));
393
+ if (owned.length === 0)
394
+ return;
395
+ const annotated = await this.annotateVtxos(owned);
396
+ const byAddress = new Map();
397
+ for (const vtxo of annotated) {
398
+ const contract = scriptToContract.get(vtxo.script);
399
+ if (!contract)
400
+ continue;
401
+ const address = contract.address;
402
+ const arr = byAddress.get(address) ?? [];
403
+ arr.push(vtxo);
404
+ byAddress.set(address, arr);
405
+ }
406
+ for (const [address, addressVtxos] of byAddress) {
407
+ const contract = contracts.find((c) => c.address === address);
408
+ if (contract) {
409
+ await (0, vtxoOwnership_1.saveVtxosForContract)(this.config.walletRepository, contract, addressVtxos);
410
+ }
411
+ else {
412
+ await this.config.walletRepository.saveVtxos(address, addressVtxos);
413
+ }
414
+ }
415
+ }
376
416
  /**
377
417
  * Check if currently watching.
378
418
  */
@@ -413,13 +453,9 @@ class ContractManager {
413
453
  this.emitEvent(event);
414
454
  }
415
455
  async getVtxosForContracts(contracts) {
416
- const res = await Promise.all(contracts.map(({ script, address }) => this.config.walletRepository.getVtxos(address).then((vtxos) =>
417
- // Address buckets may carry legacy duplicate rows from
418
- // other contracts that once shared the same address —
419
- // gate by script so the wrong-script row never wins.
420
- (0, vtxoOwnership_1.filterVtxosForScript)(vtxos, script).map((vtxo) => ({
456
+ const res = await Promise.all(contracts.map((contract) => (0, vtxoOwnership_1.getVtxosForContract)(this.config.walletRepository, contract).then((vtxos) => vtxos.map((vtxo) => ({
421
457
  ...vtxo,
422
- contractScript: script,
458
+ contractScript: contract.script,
423
459
  })))));
424
460
  return res.flat();
425
461
  }
@@ -492,7 +528,7 @@ class ContractManager {
492
528
  const filtered = (0, vtxoOwnership_1.warnAndFilterVtxosForScript)(contractVtxos, contract.script, "ContractManager.reconcilePendingFrontier");
493
529
  if (filtered.length === 0)
494
530
  continue;
495
- await this.config.walletRepository.saveVtxos(addr, filtered);
531
+ await (0, vtxoOwnership_1.saveVtxosForContract)(this.config.walletRepository, contract, filtered);
496
532
  }
497
533
  }
498
534
  async fetchContractVxosFromIndexer(contracts, pageSize, syncWindow) {
@@ -505,7 +541,7 @@ class ContractManager {
505
541
  const filtered = (0, vtxoOwnership_1.warnAndFilterVtxosForScript)(vtxos, contract.script, "ContractManager.fetchContractVxosFromIndexer");
506
542
  if (filtered.length === 0)
507
543
  continue;
508
- await this.config.walletRepository.saveVtxos(contract.address, filtered);
544
+ await (0, vtxoOwnership_1.saveVtxosForContract)(this.config.walletRepository, contract, filtered);
509
545
  }
510
546
  }
511
547
  return result;
@@ -94,7 +94,7 @@ class ContractWatcher {
94
94
  // Apply the same script gate used by getContractVtxos so a legacy
95
95
  // wrong-script row in the address bucket can't seed the baseline
96
96
  // and then look "spent" on the first poll.
97
- const cached = (0, vtxoOwnership_1.filterVtxosForScript)(await this.config.walletRepository.getVtxos(state.contract.address), state.contract.script);
97
+ const cached = await (0, vtxoOwnership_1.getVtxosForContract)(this.config.walletRepository, state.contract);
98
98
  for (const vtxo of cached) {
99
99
  if (vtxo.isSpent)
100
100
  continue;
@@ -176,7 +176,7 @@ class ContractWatcher {
176
176
  // Use contract address as cache key. Legacy address buckets
177
177
  // can contain rows from other contracts; gate by script before
178
178
  // converting so a wrong-script row never reaches the watcher.
179
- const cached = (0, vtxoOwnership_1.filterVtxosForScript)(await repo.getVtxos(state.contract.address), state.contract.script);
179
+ const cached = await (0, vtxoOwnership_1.getVtxosForContract)(repo, state.contract);
180
180
  if (cached.length > 0) {
181
181
  // Convert to ContractVtxo with contractScript
182
182
  const contractVtxos = cached.map((v) => ({
@@ -5,6 +5,8 @@ exports.isVtxoForScript = isVtxoForScript;
5
5
  exports.filterVtxosForScript = filterVtxosForScript;
6
6
  exports.warnAndFilterVtxosForScript = warnAndFilterVtxosForScript;
7
7
  exports.validateVtxosForScript = validateVtxosForScript;
8
+ exports.getVtxosForContract = getVtxosForContract;
9
+ exports.saveVtxosForContract = saveVtxosForContract;
8
10
  /**
9
11
  * Tier 1 helpers that enforce VTXO ownership at call sites that already know
10
12
  * the intended contract script. Address-keyed repositories may still hand back
@@ -58,3 +60,19 @@ function validateVtxosForScript(vtxos, script, context) {
58
60
  .join(", ");
59
61
  throw new Error(`${context}: refusing to persist ${mismatches.length} VTXO(s) whose script does not match ${script}: ${detail}`);
60
62
  }
63
+ /**
64
+ * Tier 2 dispatch helpers: route to script-scoped repository methods when
65
+ * available, falling back to Tier 1 address-based filtering otherwise.
66
+ */
67
+ async function getVtxosForContract(repo, contract) {
68
+ return repo.getVtxosForScript
69
+ ? repo.getVtxosForScript(contract.script)
70
+ : filterVtxosForScript(await repo.getVtxos(contract.address), contract.script);
71
+ }
72
+ async function saveVtxosForContract(repo, contract, vtxos) {
73
+ if (repo.saveVtxosForScript) {
74
+ return repo.saveVtxosForScript({ script: contract.script, address: contract.address }, vtxos);
75
+ }
76
+ validateVtxosForScript(vtxos, contract.script, "saveVtxosForContract");
77
+ return repo.saveVtxos(contract.address, vtxos);
78
+ }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.InMemoryWalletRepository = void 0;
4
+ const vtxoOwnership_1 = require("../../contracts/vtxoOwnership");
4
5
  /**
5
6
  * In-memory implementation of WalletRepository.
6
7
  * Data is ephemeral and scoped to the instance.
@@ -24,6 +25,40 @@ class InMemoryWalletRepository {
24
25
  async deleteVtxos(address) {
25
26
  this.vtxosByAddress.delete(address);
26
27
  }
28
+ async getVtxosForScript(script) {
29
+ const allMatches = [];
30
+ for (const bucket of this.vtxosByAddress.values()) {
31
+ for (const vtxo of bucket) {
32
+ if ((0, vtxoOwnership_1.isVtxoForScript)(vtxo, script)) {
33
+ allMatches.push(vtxo);
34
+ }
35
+ }
36
+ }
37
+ // Dedup by outpoint (last-write-wins across address buckets)
38
+ return mergeByKey([], allMatches, (item) => `${item.txid}:${item.vout}`);
39
+ }
40
+ async saveVtxosForScript(key, vtxos) {
41
+ if (!key.address) {
42
+ throw new Error("InMemoryWalletRepository requires an address");
43
+ }
44
+ for (const vtxo of vtxos) {
45
+ if (!(0, vtxoOwnership_1.isVtxoForScript)(vtxo, key.script)) {
46
+ throw new Error(`VTXO ${vtxo.txid}:${vtxo.vout} script mismatch: expected ${key.script}, got ${vtxo.script}`);
47
+ }
48
+ }
49
+ return this.saveVtxos(key.address, vtxos);
50
+ }
51
+ async deleteVtxosForScript(script) {
52
+ for (const [address, bucket] of this.vtxosByAddress.entries()) {
53
+ const next = bucket.filter((v) => !(0, vtxoOwnership_1.isVtxoForScript)(v, script));
54
+ if (next.length === 0) {
55
+ this.vtxosByAddress.delete(address);
56
+ }
57
+ else {
58
+ this.vtxosByAddress.set(address, next);
59
+ }
60
+ }
61
+ }
27
62
  async getUtxos(address) {
28
63
  return this.utxosByAddress.get(address) ?? [];
29
64
  }
@@ -6,6 +6,7 @@ const manager_1 = require("./manager");
6
6
  const schema_1 = require("./schema");
7
7
  const scriptFromAddress_1 = require("../scriptFromAddress");
8
8
  const utils_1 = require("../../worker/browser/utils");
9
+ const vtxoOwnership_1 = require("../../contracts/vtxoOwnership");
9
10
  /**
10
11
  * IndexedDB-based implementation of WalletRepository.
11
12
  */
@@ -144,6 +145,85 @@ class IndexedDBWalletRepository {
144
145
  throw error;
145
146
  }
146
147
  }
148
+ async getVtxosForScript(script) {
149
+ try {
150
+ const db = await this.getDB();
151
+ return new Promise((resolve, reject) => {
152
+ const transaction = db.transaction([db_1.STORE_VTXOS], "readonly");
153
+ const store = transaction.objectStore(db_1.STORE_VTXOS);
154
+ const index = store.index("script");
155
+ const request = index.getAll(script);
156
+ request.onerror = () => reject(request.error);
157
+ request.onsuccess = () => {
158
+ const results = request.result || [];
159
+ try {
160
+ // Defensive filter: only rows whose script matches.
161
+ const matching = results.filter((r) => r.script === script);
162
+ // Dedup same outpoint rows across address buckets.
163
+ // Work on raw rows so the address field is available
164
+ // for the canonicality tiebreaker.
165
+ const byOutpoint = new Map();
166
+ for (const row of matching) {
167
+ const outpoint = `${row.txid}:${row.vout}`;
168
+ const existing = byOutpoint.get(outpoint);
169
+ if (!existing) {
170
+ byOutpoint.set(outpoint, row);
171
+ continue;
172
+ }
173
+ if (shouldReplaceVtxo(existing, row)) {
174
+ byOutpoint.set(outpoint, row);
175
+ }
176
+ }
177
+ resolve(Array.from(byOutpoint.values()).map(deserializeVtxoWithBackfill));
178
+ }
179
+ catch (err) {
180
+ reject(err);
181
+ }
182
+ };
183
+ });
184
+ }
185
+ catch (error) {
186
+ console.error(`Failed to get VTXOs for script ${script}:`, error);
187
+ throw error;
188
+ }
189
+ }
190
+ async saveVtxosForScript(key, vtxos) {
191
+ if (!key.address) {
192
+ throw new Error("IndexedDBWalletRepository requires an address");
193
+ }
194
+ for (const vtxo of vtxos) {
195
+ if (!(0, vtxoOwnership_1.isVtxoForScript)(vtxo, key.script)) {
196
+ throw new Error(`VTXO ${vtxo.txid}:${vtxo.vout} script mismatch: expected ${key.script}, got ${vtxo.script}`);
197
+ }
198
+ }
199
+ return this.saveVtxos(key.address, vtxos);
200
+ }
201
+ async deleteVtxosForScript(script) {
202
+ try {
203
+ const db = await this.getDB();
204
+ return new Promise((resolve, reject) => {
205
+ const transaction = db.transaction([db_1.STORE_VTXOS], "readwrite");
206
+ const store = transaction.objectStore(db_1.STORE_VTXOS);
207
+ const index = store.index("script");
208
+ const request = index.openCursor(IDBKeyRange.only(script));
209
+ request.onerror = () => reject(request.error);
210
+ request.onsuccess = () => {
211
+ const cursor = request.result;
212
+ if (cursor) {
213
+ cursor.delete();
214
+ cursor.continue();
215
+ }
216
+ else {
217
+ resolve();
218
+ }
219
+ };
220
+ });
221
+ }
222
+ catch (error) {
223
+ console.error(`Failed to clear VTXOs for script ${script}:`, error);
224
+ throw error;
225
+ }
226
+ }
147
227
  async getUtxos(address) {
148
228
  try {
149
229
  const db = await this.getDB();
@@ -355,3 +435,40 @@ function deserializeVtxoWithBackfill(o) {
355
435
  }
356
436
  return (0, db_1.deserializeVtxo)(o);
357
437
  }
438
+ function isCanonicalRow(row) {
439
+ try {
440
+ return (0, scriptFromAddress_1.scriptFromArkAddress)(row.address) === row.script;
441
+ }
442
+ catch {
443
+ return false;
444
+ }
445
+ }
446
+ function shouldReplaceVtxo(existing, incoming) {
447
+ const existingCanonical = isCanonicalRow(existing);
448
+ const incomingCanonical = isCanonicalRow(incoming);
449
+ if (incomingCanonical && !existingCanonical)
450
+ return true;
451
+ if (existingCanonical && !incomingCanonical)
452
+ return false;
453
+ // Tie on canonicality, check lifecycle completeness
454
+ const existingWeight = getLifecycleWeight(existing);
455
+ const incomingWeight = getLifecycleWeight(incoming);
456
+ if (incomingWeight > existingWeight)
457
+ return true;
458
+ if (existingWeight > incomingWeight)
459
+ return false;
460
+ // Tie on weight, stable sort by address
461
+ return incoming.address < existing.address;
462
+ }
463
+ function getLifecycleWeight(v) {
464
+ let weight = 0;
465
+ if (v.isSpent !== undefined)
466
+ weight += 1;
467
+ if (v.spentBy)
468
+ weight += 2;
469
+ if (v.settledBy)
470
+ weight += 2;
471
+ if (v.arkTxId)
472
+ weight += 2;
473
+ return weight;
474
+ }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RealmWalletRepository = void 0;
4
4
  const serialization_1 = require("../serialization");
5
5
  const scriptFromAddress_1 = require("../scriptFromAddress");
6
+ const vtxoOwnership_1 = require("../../contracts/vtxoOwnership");
6
7
  /**
7
8
  * Realm-based implementation of WalletRepository.
8
9
  *
@@ -88,6 +89,33 @@ class RealmWalletRepository {
88
89
  this.realm.delete(toDelete);
89
90
  });
90
91
  }
92
+ async getVtxosForScript(script) {
93
+ await this.ensureInit();
94
+ const results = this.realm
95
+ .objects("ArkVtxo")
96
+ .filtered("script == $0", script);
97
+ return [...results].map(vtxoObjectToDomain);
98
+ }
99
+ async saveVtxosForScript(key, vtxos) {
100
+ if (!key.address) {
101
+ throw new Error("RealmWalletRepository requires an address");
102
+ }
103
+ for (const vtxo of vtxos) {
104
+ if (!(0, vtxoOwnership_1.isVtxoForScript)(vtxo, key.script)) {
105
+ throw new Error(`VTXO ${vtxo.txid}:${vtxo.vout} script mismatch: expected ${key.script}, got ${vtxo.script}`);
106
+ }
107
+ }
108
+ return this.saveVtxos(key.address, vtxos);
109
+ }
110
+ async deleteVtxosForScript(script) {
111
+ await this.ensureInit();
112
+ this.realm.write(() => {
113
+ const toDelete = this.realm
114
+ .objects("ArkVtxo")
115
+ .filtered("script == $0", script);
116
+ this.realm.delete(toDelete);
117
+ });
118
+ }
91
119
  // ── UTXO management ────────────────────────────────────────────────
92
120
  async getUtxos(address) {
93
121
  await this.ensureInit();
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SQLiteWalletRepository = void 0;
4
4
  const serialization_1 = require("../serialization");
5
5
  const scriptFromAddress_1 = require("../scriptFromAddress");
6
+ const vtxoOwnership_1 = require("../../contracts/vtxoOwnership");
6
7
  /**
7
8
  * SQLite-based implementation of WalletRepository.
8
9
  *
@@ -245,6 +246,28 @@ class SQLiteWalletRepository {
245
246
  await this.ensureInit();
246
247
  await this.db.run(`DELETE FROM ${this.tables.vtxos} WHERE address = ?`, [address]);
247
248
  }
249
+ async getVtxosForScript(script) {
250
+ await this.ensureInit();
251
+ const rows = await this.db.all(`SELECT * FROM ${this.tables.vtxos} WHERE script = ?`, [script]);
252
+ return rows.map(vtxoRowToDomain);
253
+ }
254
+ async saveVtxosForScript(key, vtxos) {
255
+ if (!key.address) {
256
+ throw new Error("SQLiteWalletRepository requires an address");
257
+ }
258
+ for (const vtxo of vtxos) {
259
+ if (!(0, vtxoOwnership_1.isVtxoForScript)(vtxo, key.script)) {
260
+ throw new Error(`VTXO ${vtxo.txid}:${vtxo.vout} script mismatch: expected ${key.script}, got ${vtxo.script}`);
261
+ }
262
+ }
263
+ return this.saveVtxos(key.address, vtxos);
264
+ }
265
+ async deleteVtxosForScript(script) {
266
+ await this.ensureInit();
267
+ await this.db.run(`DELETE FROM ${this.tables.vtxos} WHERE script = ?`, [
268
+ script,
269
+ ]);
270
+ }
248
271
  // ── UTXO management ────────────────────────────────────────────────
249
272
  async getUtxos(address) {
250
273
  await this.ensureInit();
@@ -319,6 +319,16 @@ class WalletMessageHandler {
319
319
  type: "REFRESH_VTXOS_SUCCESS",
320
320
  });
321
321
  }
322
+ case "REFRESH_OUTPOINTS": {
323
+ const manager = await this.readonlyWallet.getContractManager();
324
+ const { outpoints } = message
325
+ .payload;
326
+ await manager.refreshOutpoints(outpoints);
327
+ return this.tagged({
328
+ id,
329
+ type: "REFRESH_OUTPOINTS_SUCCESS",
330
+ });
331
+ }
322
332
  case "SEND": {
323
333
  const { recipients } = message.payload;
324
334
  const txid = await this.wallet.send(...recipients);
@@ -626,7 +636,9 @@ class WalletMessageHandler {
626
636
  : addrByScript.get(script);
627
637
  if (!targetAddress)
628
638
  continue;
629
- await this.walletRepository?.saveVtxos(targetAddress, filtered);
639
+ if (this.walletRepository) {
640
+ await (0, vtxoOwnership_1.saveVtxosForContract)(this.walletRepository, { script, address: targetAddress }, filtered);
641
+ }
630
642
  }
631
643
  // notify all clients about the virtual output state update
632
644
  this.scheduleForNextTick(() => this.tagged({
@@ -846,8 +858,7 @@ class WalletMessageHandler {
846
858
  const manager = await this.readonlyWallet.getContractManager();
847
859
  const contracts = await manager.getContracts();
848
860
  for (const contract of contracts) {
849
- const vtxos = await this.walletRepository.getVtxos(contract.address);
850
- addVtxos((0, vtxoOwnership_1.filterVtxosForScript)(vtxos, contract.script));
861
+ addVtxos(await (0, vtxoOwnership_1.getVtxosForContract)(this.walletRepository, contract));
851
862
  }
852
863
  // Also check the wallet's primary address. Decode it to its script
853
864
  // and apply the same script gate. Failing to decode the wallet's own
@@ -60,6 +60,7 @@ exports.DEFAULT_MESSAGE_TIMEOUTS = {
60
60
  UPDATE_CONTRACT: 30000,
61
61
  DELETE_CONTRACT: 10000,
62
62
  REFRESH_VTXOS: 30000,
63
+ REFRESH_OUTPOINTS: 30000,
63
64
  };
64
65
  const DEDUPABLE_REQUEST_TYPES = new Set([
65
66
  "GET_ADDRESS",
@@ -824,6 +825,15 @@ class ServiceWorkerReadonlyWallet {
824
825
  };
825
826
  await sendContractMessage(message);
826
827
  },
828
+ async refreshOutpoints(outpoints) {
829
+ const message = {
830
+ type: "REFRESH_OUTPOINTS",
831
+ id: (0, utils_2.getRandomId)(),
832
+ tag: messageTag,
833
+ payload: { outpoints },
834
+ };
835
+ await sendContractMessage(message);
836
+ },
827
837
  async isWatching() {
828
838
  const message = {
829
839
  type: "IS_CONTRACT_MANAGER_WATCHING",
@@ -4,6 +4,7 @@ exports.VtxoManager = exports.DEFAULT_SETTLEMENT_CONFIG = exports.DEFAULT_RENEWA
4
4
  exports.isVtxoExpiringSoon = isVtxoExpiringSoon;
5
5
  exports.getExpiringAndRecoverableVtxos = getExpiringAndRecoverableVtxos;
6
6
  const _1 = require(".");
7
+ const errors_1 = require("../providers/errors");
7
8
  const arkTransaction_1 = require("../utils/arkTransaction");
8
9
  const tapscript_1 = require("../script/tapscript");
9
10
  const base_1 = require("@scure/base");
@@ -437,10 +438,22 @@ class VtxoManager {
437
438
  try {
438
439
  // Get all virtual outputs (including recoverable ones)
439
440
  // Use default threshold to bypass settlementConfig gate (manual API should always work)
440
- const vtxos = await this.getExpiringVtxos(this.settlementConfig !== false &&
441
+ const threshold = this.settlementConfig !== false &&
441
442
  this.settlementConfig?.vtxoThreshold !== undefined
442
443
  ? this.settlementConfig.vtxoThreshold * 1000
443
- : exports.DEFAULT_RENEWAL_CONFIG.thresholdMs);
444
+ : exports.DEFAULT_RENEWAL_CONFIG.thresholdMs;
445
+ let vtxos = await this.getExpiringVtxos(threshold);
446
+ if (vtxos.length === 0) {
447
+ throw new Error("No VTXOs available to renew");
448
+ }
449
+ // Pre-flight: validate the chosen inputs against the indexer's
450
+ // authoritative state before submitting. The cursor-derived
451
+ // delta sync filters by `created_at`, so a VTXO created
452
+ // before the cursor and spent recently can sit in the local
453
+ // cache forever; settling against it yields a guaranteed
454
+ // VTXO_ALREADY_SPENT 400. Refreshing the candidates here
455
+ // catches that BEFORE the network round-trip.
456
+ vtxos = await this.revalidateBeforeSettle(vtxos, threshold);
444
457
  if (vtxos.length === 0) {
445
458
  throw new Error("No VTXOs available to renew");
446
459
  }
@@ -689,9 +702,11 @@ class VtxoManager {
689
702
  if (e.message.includes("VTXO_ALREADY_SPENT")) {
690
703
  // Our local VTXO cache is stale vs. the
691
704
  // server's authoritative view. Trigger a
692
- // throttled refresh to reconcile, then skip
693
- // the next cycle will see fresh data.
694
- void this.maybeRefreshAfterVtxoSpent();
705
+ // throttled, targeted refresh on the
706
+ // offending outpoint (if the server told
707
+ // us which one), then skip — the next
708
+ // cycle will see fresh data.
709
+ void this.maybeRefreshAfterVtxoSpent(this.extractSpentOutpoint(e));
695
710
  return;
696
711
  }
697
712
  }
@@ -716,13 +731,20 @@ class VtxoManager {
716
731
  /**
717
732
  * VTXO_ALREADY_SPENT means the server's authoritative view of VTXO state
718
733
  * is ahead of ours — cross-instance race, pre-lock snapshot drift, or an
719
- * SSE gap left stale data in the local cache. Silent-swallowing guarantees
720
- * the same error on the next cycle because nothing reconciles the cache,
721
- * so instead we trigger a full refreshVtxos() to advance the global sync
722
- * cursor. Throttled to prevent a buggy indexer from causing a refresh
723
- * storm.
734
+ * SSE gap left stale data in the local cache. Silent-swallowing
735
+ * guarantees the same error on the next cycle because nothing
736
+ * reconciles the cache.
737
+ *
738
+ * The cursor-derived delta sync filters by `created_at`, so a VTXO that
739
+ * was created before the cursor but spent recently can never be
740
+ * reconciled by `refreshVtxos()`. Use `refreshOutpoints` for surgical
741
+ * recovery: query the indexer for the specific stale outpoint and
742
+ * upsert its authoritative state into the wallet repository.
743
+ *
744
+ * Throttled because the same VTXO can fire repeatedly before the
745
+ * upsert observably propagates through the renewal selector.
724
746
  */
725
- maybeRefreshAfterVtxoSpent() {
747
+ maybeRefreshAfterVtxoSpent(spentOutpoint) {
726
748
  if (this.vtxoSpentRefreshPromise) {
727
749
  return this.vtxoSpentRefreshPromise;
728
750
  }
@@ -735,7 +757,13 @@ class VtxoManager {
735
757
  this.vtxoSpentRefreshPromise = (async () => {
736
758
  try {
737
759
  const contractManager = await this.wallet.getContractManager();
738
- await contractManager.refreshVtxos();
760
+ if (spentOutpoint) {
761
+ await contractManager.refreshOutpoints([spentOutpoint]);
762
+ }
763
+ else {
764
+ // No outpoint metadata — fall back to the broader refresh.
765
+ await contractManager.refreshVtxos();
766
+ }
739
767
  }
740
768
  catch (e) {
741
769
  console.error("Error refreshing VTXOs after VTXO_ALREADY_SPENT:", e);
@@ -746,6 +774,66 @@ class VtxoManager {
746
774
  })();
747
775
  return this.vtxoSpentRefreshPromise;
748
776
  }
777
+ /**
778
+ * Extract the offending VTXO outpoint from a `VTXO_ALREADY_SPENT` error,
779
+ * if the server attached one in `metadata.vtxo_outpoint`. Returns
780
+ * `undefined` when the error isn't a parsed ArkError, isn't this code,
781
+ * or doesn't carry the metadata.
782
+ */
783
+ extractSpentOutpoint(error) {
784
+ const ark = (0, errors_1.maybeArkError)(error);
785
+ if (!ark || ark.name !== "VTXO_ALREADY_SPENT")
786
+ return undefined;
787
+ const raw = ark.metadata?.vtxo_outpoint;
788
+ if (typeof raw !== "string")
789
+ return undefined;
790
+ const [txid, voutStr] = raw.split(":");
791
+ if (!txid || !voutStr)
792
+ return undefined;
793
+ const vout = Number(voutStr);
794
+ if (!Number.isInteger(vout) || vout < 0)
795
+ return undefined;
796
+ return { txid, vout };
797
+ }
798
+ /**
799
+ * Reconcile the chosen VTXOs with the indexer's authoritative state
800
+ * before submitting a settle intent. Pulls the canonical record for
801
+ * each candidate outpoint via {@link IContractManager.refreshOutpoints}
802
+ * (which upserts the result into the wallet repository), then
803
+ * re-selects through the standard expiring-vtxo filter so anything
804
+ * the refresh flagged as spent is dropped.
805
+ *
806
+ * Best-effort: a failed refresh just falls back to the original
807
+ * candidates and lets the post-submit `VTXO_ALREADY_SPENT` recovery
808
+ * handle whatever slipped through.
809
+ */
810
+ async revalidateBeforeSettle(candidates, thresholdMs) {
811
+ if (candidates.length === 0)
812
+ return candidates;
813
+ try {
814
+ const cm = await this.wallet.getContractManager();
815
+ await cm.refreshOutpoints(candidates.map((v) => ({ txid: v.txid, vout: v.vout })));
816
+ }
817
+ catch (e) {
818
+ console.error("Error pre-validating VTXOs before settle:", e);
819
+ return candidates;
820
+ }
821
+ // Re-select from the now-fresh local cache. Anything previously
822
+ // selected but spent gets filtered out by the standard
823
+ // `isSpendable`/`isSpent` checks inside getVtxos / getExpiringVtxos.
824
+ try {
825
+ const refreshed = await this.getExpiringVtxos(thresholdMs);
826
+ const candidateKeys = new Set(candidates.map((v) => `${v.txid}:${v.vout}`));
827
+ // Restrict to vtxos that were also in the original candidate set
828
+ // — `getExpiringVtxos` may surface NEW vtxos and we don't want
829
+ // pre-flight to silently expand the input set.
830
+ return refreshed.filter((v) => candidateKeys.has(`${v.txid}:${v.vout}`));
831
+ }
832
+ catch (e) {
833
+ console.error("Error re-selecting VTXOs after pre-validate:", e);
834
+ return candidates;
835
+ }
836
+ }
749
837
  /** Computes the next poll delay, applying exponential backoff on failures. */
750
838
  getNextPollDelay() {
751
839
  if (this.settlementConfig === false)
@@ -887,6 +975,13 @@ class VtxoManager {
887
975
  if (!this.renewalInProgress) {
888
976
  try {
889
977
  expiringVtxos = await this.getExpiringVtxos();
978
+ // Pre-flight validation: see comment in `renewVtxos`. The
979
+ // local cache may carry vtxos that the indexer already
980
+ // marks spent because the cursor-derived delta sync only
981
+ // catches `created_at`-recent updates, not status changes
982
+ // for older VTXOs.
983
+ expiringVtxos =
984
+ await this.revalidateBeforeSettle(expiringVtxos);
890
985
  }
891
986
  catch (e) {
892
987
  // Non-fatal: fall back to boarding-only settle.
@@ -978,11 +1073,12 @@ class VtxoManager {
978
1073
  e.message.includes("VTXO_ALREADY_SPENT")) {
979
1074
  // Local VTXO cache is stale vs. the server's
980
1075
  // authoritative view — not a transient failure.
981
- // Trigger a throttled refresh and skip this cycle
982
- // without bumping the failure counter, so the next
983
- // poll can retry once the cache reconciles.
1076
+ // Trigger a throttled, targeted refresh on the
1077
+ // offending outpoint and skip this cycle without
1078
+ // bumping the failure counter, so the next poll
1079
+ // can retry once the cache reconciles.
984
1080
  staleCacheSkip = true;
985
- void this.maybeRefreshAfterVtxoSpent();
1081
+ void this.maybeRefreshAfterVtxoSpent(this.extractSpentOutpoint(e));
986
1082
  }
987
1083
  else {
988
1084
  throw e;