@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.
- package/dist/cjs/contracts/contractManager.js +44 -8
- package/dist/cjs/contracts/contractWatcher.js +2 -2
- package/dist/cjs/contracts/vtxoOwnership.js +18 -0
- package/dist/cjs/repositories/inMemory/walletRepository.js +35 -0
- package/dist/cjs/repositories/indexedDB/walletRepository.js +117 -0
- package/dist/cjs/repositories/realm/walletRepository.js +28 -0
- package/dist/cjs/repositories/sqlite/walletRepository.js +23 -0
- package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +14 -3
- package/dist/cjs/wallet/serviceWorker/wallet.js +10 -0
- package/dist/cjs/wallet/vtxo-manager.js +112 -16
- package/dist/cjs/wallet/wallet.js +3 -17
- package/dist/cjs/worker/expo/processors/contractPollProcessor.js +1 -1
- package/dist/esm/contracts/contractManager.js +45 -9
- package/dist/esm/contracts/contractWatcher.js +3 -3
- package/dist/esm/contracts/vtxoOwnership.js +16 -0
- package/dist/esm/repositories/inMemory/walletRepository.js +35 -0
- package/dist/esm/repositories/indexedDB/walletRepository.js +117 -0
- package/dist/esm/repositories/realm/walletRepository.js +28 -0
- package/dist/esm/repositories/sqlite/walletRepository.js +23 -0
- package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +15 -4
- package/dist/esm/wallet/serviceWorker/wallet.js +10 -0
- package/dist/esm/wallet/vtxo-manager.js +112 -16
- package/dist/esm/wallet/wallet.js +4 -18
- package/dist/esm/worker/expo/processors/contractPollProcessor.js +2 -2
- package/dist/types/contracts/contractManager.d.ts +17 -1
- package/dist/types/contracts/vtxoOwnership.d.ts +9 -1
- package/dist/types/repositories/inMemory/walletRepository.d.ts +4 -1
- package/dist/types/repositories/indexedDB/walletRepository.d.ts +4 -1
- package/dist/types/repositories/realm/walletRepository.d.ts +4 -1
- package/dist/types/repositories/sqlite/walletRepository.d.ts +4 -1
- package/dist/types/repositories/walletRepository.d.ts +21 -0
- package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +14 -2
- package/dist/types/wallet/vtxo-manager.d.ts +32 -5
- 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((
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
693
|
-
//
|
|
694
|
-
|
|
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
|
|
720
|
-
* the same error on the next cycle because nothing
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
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
|
-
|
|
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
|
|
982
|
-
//
|
|
983
|
-
//
|
|
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;
|