@arkade-os/sdk 0.4.17 → 0.4.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -6
- package/dist/cjs/contracts/arkcontract.js +0 -2
- package/dist/cjs/contracts/contractManager.js +111 -215
- package/dist/cjs/contracts/contractWatcher.js +86 -115
- package/dist/cjs/providers/ark.js +36 -33
- package/dist/cjs/repositories/indexedDB/manager.js +6 -3
- package/dist/cjs/repositories/indexedDB/schema.js +47 -2
- package/dist/cjs/repositories/indexedDB/walletRepository.js +21 -2
- package/dist/cjs/repositories/realm/contractRepository.js +0 -4
- package/dist/cjs/repositories/realm/index.js +3 -1
- package/dist/cjs/repositories/realm/schemas.js +50 -1
- package/dist/cjs/repositories/realm/walletRepository.js +8 -4
- package/dist/cjs/repositories/scriptFromAddress.js +16 -0
- package/dist/cjs/repositories/sqlite/contractRepository.js +2 -6
- package/dist/cjs/repositories/sqlite/walletRepository.js +121 -33
- package/dist/cjs/utils/syncCursors.js +48 -56
- package/dist/cjs/wallet/expo/background.js +0 -13
- package/dist/cjs/wallet/expo/wallet.js +1 -6
- package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +16 -7
- package/dist/cjs/wallet/serviceWorker/wallet.js +19 -0
- package/dist/cjs/wallet/utils.js +41 -10
- package/dist/cjs/wallet/vtxo-manager.js +222 -40
- package/dist/cjs/wallet/wallet.js +149 -211
- package/dist/cjs/worker/expo/processors/contractPollProcessor.js +9 -13
- package/dist/cjs/worker/expo/taskRunner.js +2 -11
- package/dist/esm/contracts/arkcontract.js +0 -2
- package/dist/esm/contracts/contractManager.js +113 -217
- package/dist/esm/contracts/contractWatcher.js +86 -115
- package/dist/esm/providers/ark.js +36 -33
- package/dist/esm/repositories/indexedDB/manager.js +6 -3
- package/dist/esm/repositories/indexedDB/schema.js +46 -2
- package/dist/esm/repositories/indexedDB/walletRepository.js +21 -2
- package/dist/esm/repositories/realm/contractRepository.js +0 -4
- package/dist/esm/repositories/realm/index.js +1 -1
- package/dist/esm/repositories/realm/schemas.js +48 -0
- package/dist/esm/repositories/realm/walletRepository.js +8 -4
- package/dist/esm/repositories/scriptFromAddress.js +13 -0
- package/dist/esm/repositories/sqlite/contractRepository.js +2 -6
- package/dist/esm/repositories/sqlite/walletRepository.js +121 -33
- package/dist/esm/utils/syncCursors.js +47 -53
- package/dist/esm/wallet/expo/background.js +0 -13
- package/dist/esm/wallet/expo/wallet.js +2 -7
- package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +17 -8
- package/dist/esm/wallet/serviceWorker/wallet.js +19 -0
- package/dist/esm/wallet/utils.js +41 -9
- package/dist/esm/wallet/vtxo-manager.js +222 -40
- package/dist/esm/wallet/wallet.js +152 -214
- package/dist/esm/worker/expo/processors/contractPollProcessor.js +9 -13
- package/dist/esm/worker/expo/taskRunner.js +3 -12
- package/dist/types/contracts/arkcontract.d.ts +0 -2
- package/dist/types/contracts/contractManager.d.ts +38 -9
- package/dist/types/contracts/contractWatcher.d.ts +22 -21
- package/dist/types/contracts/types.d.ts +0 -7
- package/dist/types/repositories/indexedDB/manager.d.ts +5 -2
- package/dist/types/repositories/indexedDB/schema.d.ts +3 -2
- package/dist/types/repositories/realm/index.d.ts +1 -1
- package/dist/types/repositories/realm/schemas.d.ts +41 -0
- package/dist/types/repositories/scriptFromAddress.d.ts +9 -0
- package/dist/types/repositories/serialization.d.ts +1 -1
- package/dist/types/repositories/sqlite/walletRepository.d.ts +22 -0
- package/dist/types/repositories/walletRepository.d.ts +10 -2
- package/dist/types/utils/syncCursors.d.ts +25 -23
- package/dist/types/wallet/index.d.ts +1 -1
- package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +15 -3
- package/dist/types/wallet/utils.d.ts +20 -4
- package/dist/types/wallet/vtxo-manager.d.ts +29 -6
- package/dist/types/wallet/wallet.d.ts +8 -17
- package/dist/types/worker/expo/processors/contractPollProcessor.d.ts +9 -4
- package/dist/types/worker/expo/taskRunner.d.ts +6 -3
- package/package.json +1 -1
|
@@ -62,6 +62,12 @@ export class ContractWatcher {
|
|
|
62
62
|
lastKnownVtxos: new Map(),
|
|
63
63
|
};
|
|
64
64
|
this.contracts.set(contract.script, state);
|
|
65
|
+
// Seed the baseline from the repository BEFORE any poll or event
|
|
66
|
+
// emits. Without this, the first poll after (re)start treats every
|
|
67
|
+
// persisted vtxo as "new" and emits `vtxo_received` for each —
|
|
68
|
+
// which downstream triggers a redundant per-vtxo sync on every
|
|
69
|
+
// app launch and can confuse consumers that react to the event.
|
|
70
|
+
await this.seedLastKnownVtxos(state);
|
|
65
71
|
// If we're already watching, poll to discover virtual outputs and update subscription
|
|
66
72
|
if (this.isWatching) {
|
|
67
73
|
// Poll first to discover virtual outputs (may affect whether we watch this contract).
|
|
@@ -70,6 +76,31 @@ export class ContractWatcher {
|
|
|
70
76
|
await this.tryUpdateSubscription();
|
|
71
77
|
}
|
|
72
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Pre-populate `lastKnownVtxos` from the wallet repository.
|
|
81
|
+
*
|
|
82
|
+
* Runs on add (and can be re-run after reconnect) so polling always
|
|
83
|
+
* compares the indexer's view against what is already persisted,
|
|
84
|
+
* emitting only genuine deltas.
|
|
85
|
+
*/
|
|
86
|
+
async seedLastKnownVtxos(state) {
|
|
87
|
+
try {
|
|
88
|
+
const cached = await this.config.walletRepository.getVtxos(state.contract.address);
|
|
89
|
+
for (const vtxo of cached) {
|
|
90
|
+
if (vtxo.isSpent)
|
|
91
|
+
continue;
|
|
92
|
+
const key = `${vtxo.txid}:${vtxo.vout}`;
|
|
93
|
+
state.lastKnownVtxos.set(key, vtxo);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
// Don't throw — the watcher can still recover via poll and
|
|
98
|
+
// subscription events. A failed seed just means the first poll
|
|
99
|
+
// may emit some redundant `vtxo_received` events for already
|
|
100
|
+
// known vtxos.
|
|
101
|
+
console.error(`ContractWatcher: failed to seed lastKnownVtxos for ${state.contract.script}`, error);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
73
104
|
/**
|
|
74
105
|
* Update an existing contract.
|
|
75
106
|
*/
|
|
@@ -102,35 +133,20 @@ export class ContractWatcher {
|
|
|
102
133
|
return Array.from(this.contracts.values()).map((s) => s.contract);
|
|
103
134
|
}
|
|
104
135
|
/**
|
|
105
|
-
*
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Get scripts that should be watched.
|
|
136
|
+
* Contracts the watcher is actually tracking:
|
|
137
|
+
* - all active contracts, plus
|
|
138
|
+
* - inactive contracts that still hold known virtual outputs
|
|
139
|
+
* (the subscription keeps watching them so `vtxo_spent` events for
|
|
140
|
+
* those unspent outputs are still observed).
|
|
112
141
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* This ensures we continue monitoring contracts even after they're
|
|
118
|
-
* deactivated, as long as they have unspent virtual outputs.
|
|
142
|
+
* This is the single source of truth for "contracts whose VTXO state
|
|
143
|
+
* we still care about" — callers and the subscription itself fan out
|
|
144
|
+
* over the same set so nothing is reconciled that isn't also watched.
|
|
119
145
|
*/
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (state.contract.state === "active") {
|
|
125
|
-
scripts.add(state.contract.script);
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
// Also watch inactive/expired contracts that have virtual outputs.
|
|
129
|
-
if (state.lastKnownVtxos.size > 0) {
|
|
130
|
-
scripts.add(state.contract.script);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return Array.from(scripts);
|
|
146
|
+
getWatchedContracts() {
|
|
147
|
+
return Array.from(this.contracts.values())
|
|
148
|
+
.filter((s) => s.contract.state === "active" || s.lastKnownVtxos.size > 0)
|
|
149
|
+
.map((s) => s.contract);
|
|
134
150
|
}
|
|
135
151
|
/**
|
|
136
152
|
* Get virtual outputs for contracts, grouped by contract script.
|
|
@@ -232,28 +248,6 @@ export class ContractWatcher {
|
|
|
232
248
|
return;
|
|
233
249
|
await this.pollAllContracts();
|
|
234
250
|
}
|
|
235
|
-
/**
|
|
236
|
-
* Check for expired contracts, update their state, and emit events.
|
|
237
|
-
*/
|
|
238
|
-
checkExpiredContracts() {
|
|
239
|
-
const now = Date.now();
|
|
240
|
-
const expired = [];
|
|
241
|
-
for (const state of this.contracts.values()) {
|
|
242
|
-
const contract = state.contract;
|
|
243
|
-
if (contract.state === "active" &&
|
|
244
|
-
contract.expiresAt &&
|
|
245
|
-
contract.expiresAt <= now) {
|
|
246
|
-
contract.state = "inactive";
|
|
247
|
-
expired.push(contract);
|
|
248
|
-
this.eventCallback?.({
|
|
249
|
-
type: "contract_expired",
|
|
250
|
-
contractScript: contract.script,
|
|
251
|
-
contract,
|
|
252
|
-
timestamp: now,
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
251
|
/**
|
|
258
252
|
* Connect to the subscription.
|
|
259
253
|
*/
|
|
@@ -329,14 +323,11 @@ export class ContractWatcher {
|
|
|
329
323
|
}
|
|
330
324
|
}, this.config.failsafePollIntervalMs);
|
|
331
325
|
}
|
|
332
|
-
/**
|
|
333
|
-
* Poll all active contracts for current state.
|
|
334
|
-
*/
|
|
335
326
|
async pollAllContracts() {
|
|
336
|
-
const
|
|
337
|
-
if (
|
|
327
|
+
const scripts = this.getWatchedContracts().map((c) => c.script);
|
|
328
|
+
if (scripts.length === 0)
|
|
338
329
|
return;
|
|
339
|
-
await this.pollContracts(
|
|
330
|
+
await this.pollContracts(scripts);
|
|
340
331
|
}
|
|
341
332
|
/**
|
|
342
333
|
* Poll specific contracts and emit events for changes.
|
|
@@ -404,7 +395,7 @@ export class ContractWatcher {
|
|
|
404
395
|
* Watches both active contracts and contracts with virtual outputs.
|
|
405
396
|
*/
|
|
406
397
|
async updateSubscription() {
|
|
407
|
-
const scriptsToWatch = this.
|
|
398
|
+
const scriptsToWatch = this.getWatchedContracts().map((c) => c.script);
|
|
408
399
|
if (scriptsToWatch.length === 0) {
|
|
409
400
|
if (this.subscriptionId) {
|
|
410
401
|
try {
|
|
@@ -469,61 +460,55 @@ export class ContractWatcher {
|
|
|
469
460
|
if (!this.eventCallback)
|
|
470
461
|
return;
|
|
471
462
|
const timestamp = Date.now();
|
|
472
|
-
const scripts = update.scripts || [];
|
|
473
463
|
if (update.newVtxos?.length) {
|
|
474
|
-
this.processSubscriptionVtxos(update.newVtxos,
|
|
464
|
+
this.processSubscriptionVtxos(update.newVtxos, "vtxo_received", timestamp);
|
|
475
465
|
}
|
|
476
466
|
if (update.spentVtxos?.length) {
|
|
477
|
-
this.processSubscriptionVtxos(update.spentVtxos,
|
|
467
|
+
this.processSubscriptionVtxos(update.spentVtxos, "vtxo_spent", timestamp);
|
|
478
468
|
}
|
|
479
469
|
}
|
|
480
470
|
/**
|
|
481
|
-
* Process virtual outputs from subscription and route to
|
|
482
|
-
*
|
|
471
|
+
* Process virtual outputs from subscription and route each VTXO to the
|
|
472
|
+
* single contract that actually locks it via `vtxo.script`. If the script
|
|
473
|
+
* doesn't match any watched contract, skip the VTXO rather than fan it
|
|
474
|
+
* out to every matching contract — fan-out produced phantom state in
|
|
475
|
+
* non-owning contracts that then never reconciled.
|
|
483
476
|
*/
|
|
484
|
-
processSubscriptionVtxos(vtxos,
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const state = this.contracts.get(contractScript);
|
|
492
|
-
if (state) {
|
|
493
|
-
for (const vtxo of vtxos) {
|
|
494
|
-
const key = `${vtxo.txid}:${vtxo.vout}`;
|
|
495
|
-
if (eventType === "vtxo_received") {
|
|
496
|
-
state.lastKnownVtxos.set(key, vtxo);
|
|
497
|
-
}
|
|
498
|
-
else if (eventType === "vtxo_spent") {
|
|
499
|
-
state.lastKnownVtxos.delete(key);
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
this.emitVtxoEvent(contractScript, vtxos, eventType, timestamp);
|
|
477
|
+
processSubscriptionVtxos(vtxos, eventType, timestamp) {
|
|
478
|
+
const byContract = new Map();
|
|
479
|
+
let unknownScript = 0;
|
|
480
|
+
for (const vtxo of vtxos) {
|
|
481
|
+
if (!this.contracts.has(vtxo.script)) {
|
|
482
|
+
unknownScript++;
|
|
483
|
+
continue;
|
|
504
484
|
}
|
|
505
|
-
|
|
485
|
+
let bucket = byContract.get(vtxo.script);
|
|
486
|
+
if (!bucket) {
|
|
487
|
+
bucket = [];
|
|
488
|
+
byContract.set(vtxo.script, bucket);
|
|
489
|
+
}
|
|
490
|
+
bucket.push(vtxo);
|
|
506
491
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
492
|
+
if (unknownScript > 0) {
|
|
493
|
+
// The failsafe poll is the backstop for these; log at debug so we
|
|
494
|
+
// can correlate "VTXO state drift" reports with subscription
|
|
495
|
+
// drops rather than chase phantom bugs.
|
|
496
|
+
console.debug(`ContractWatcher.processSubscriptionVtxos[${eventType}]: dropped ${unknownScript} unknown-script VTXOs (${vtxos.length} total)`);
|
|
497
|
+
}
|
|
498
|
+
for (const [contractScript, bucketVtxos] of byContract) {
|
|
499
|
+
const state = this.contracts.get(contractScript);
|
|
500
|
+
if (state) {
|
|
501
|
+
for (const vtxo of bucketVtxos) {
|
|
502
|
+
const key = `${vtxo.txid}:${vtxo.vout}`;
|
|
503
|
+
if (eventType === "vtxo_received") {
|
|
504
|
+
state.lastKnownVtxos.set(key, vtxo);
|
|
505
|
+
}
|
|
506
|
+
else if (eventType === "vtxo_spent") {
|
|
507
|
+
state.lastKnownVtxos.delete(key);
|
|
523
508
|
}
|
|
524
509
|
}
|
|
525
|
-
this.emitVtxoEvent(contractScript, vtxos, eventType, timestamp);
|
|
526
510
|
}
|
|
511
|
+
this.emitVtxoEvent(contractScript, bucketVtxos, eventType, timestamp);
|
|
527
512
|
}
|
|
528
513
|
}
|
|
529
514
|
/**
|
|
@@ -533,12 +518,10 @@ export class ContractWatcher {
|
|
|
533
518
|
if (!this.eventCallback)
|
|
534
519
|
return;
|
|
535
520
|
const state = this.contracts.get(contractScript);
|
|
536
|
-
|
|
537
|
-
|
|
521
|
+
if (!state)
|
|
522
|
+
return;
|
|
538
523
|
switch (eventType) {
|
|
539
524
|
case "vtxo_received":
|
|
540
|
-
if (!state)
|
|
541
|
-
return;
|
|
542
525
|
this.eventCallback({
|
|
543
526
|
type: "vtxo_received",
|
|
544
527
|
vtxos: vtxos.map((v) => ({
|
|
@@ -555,8 +538,6 @@ export class ContractWatcher {
|
|
|
555
538
|
});
|
|
556
539
|
return;
|
|
557
540
|
case "vtxo_spent":
|
|
558
|
-
if (!state)
|
|
559
|
-
return;
|
|
560
541
|
this.eventCallback({
|
|
561
542
|
type: "vtxo_spent",
|
|
562
543
|
vtxos: vtxos.map((v) => ({
|
|
@@ -572,16 +553,6 @@ export class ContractWatcher {
|
|
|
572
553
|
timestamp,
|
|
573
554
|
});
|
|
574
555
|
return;
|
|
575
|
-
case "contract_expired":
|
|
576
|
-
if (!state)
|
|
577
|
-
return;
|
|
578
|
-
this.eventCallback({
|
|
579
|
-
type: "contract_expired",
|
|
580
|
-
contractScript,
|
|
581
|
-
contract: state.contract,
|
|
582
|
-
timestamp,
|
|
583
|
-
});
|
|
584
|
-
return;
|
|
585
556
|
default:
|
|
586
557
|
return;
|
|
587
558
|
}
|
|
@@ -227,28 +227,21 @@ export class RestArkProvider {
|
|
|
227
227
|
const queryParams = topics.length > 0
|
|
228
228
|
? `?${topics.map((topic) => `topics=${encodeURIComponent(topic)}`).join("&")}`
|
|
229
229
|
: "";
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
230
|
+
// The EventSource is allocated inside the generator body so that
|
|
231
|
+
// abandoning the returned iterator before iteration starts does not
|
|
232
|
+
// leak the underlying SSE connection. `return()` is overridden below
|
|
233
|
+
// so that closing the generator also closes the connection even when
|
|
234
|
+
// the body is currently suspended at an await point.
|
|
235
|
+
let eventSource = null;
|
|
235
236
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
236
237
|
const self = this;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
? eagerIterator
|
|
245
|
-
: eventSourceIterator(eventSource);
|
|
246
|
-
firstIteration = false;
|
|
247
|
-
try {
|
|
248
|
-
const abortHandler = () => {
|
|
249
|
-
eventSource.close();
|
|
250
|
-
};
|
|
251
|
-
signal?.addEventListener("abort", abortHandler);
|
|
238
|
+
const gen = (async function* () {
|
|
239
|
+
const abortHandler = () => eventSource?.close();
|
|
240
|
+
signal?.addEventListener("abort", abortHandler);
|
|
241
|
+
try {
|
|
242
|
+
while (!signal?.aborted) {
|
|
243
|
+
eventSource = new EventSource(url + queryParams);
|
|
244
|
+
const iterator = eventSourceIterator(eventSource);
|
|
252
245
|
try {
|
|
253
246
|
for await (const event of iterator) {
|
|
254
247
|
if (signal?.aborted)
|
|
@@ -266,25 +259,35 @@ export class RestArkProvider {
|
|
|
266
259
|
}
|
|
267
260
|
}
|
|
268
261
|
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
if (error instanceof Error &&
|
|
264
|
+
error.name === "AbortError") {
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
// ignore timeout errors, they're expected when the server is not sending anything for 5 min
|
|
268
|
+
if (isFetchTimeoutError(error)) {
|
|
269
|
+
console.debug("Timeout error ignored");
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
console.error("Event stream error:", error);
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
269
275
|
finally {
|
|
270
|
-
signal?.removeEventListener("abort", abortHandler);
|
|
271
276
|
eventSource.close();
|
|
272
277
|
}
|
|
273
278
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
// ignore timeout errors, they're expected when the server is not sending anything for 5 min
|
|
279
|
-
if (isFetchTimeoutError(error)) {
|
|
280
|
-
console.debug("Timeout error ignored");
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
console.error("Event stream error:", error);
|
|
284
|
-
throw error;
|
|
285
|
-
}
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
signal?.removeEventListener("abort", abortHandler);
|
|
282
|
+
eventSource?.close();
|
|
286
283
|
}
|
|
287
284
|
})();
|
|
285
|
+
const origReturn = gen.return.bind(gen);
|
|
286
|
+
gen.return = (value) => {
|
|
287
|
+
eventSource?.close();
|
|
288
|
+
return origReturn(value);
|
|
289
|
+
};
|
|
290
|
+
return gen;
|
|
288
291
|
}
|
|
289
292
|
async *getTransactionsStream(signal) {
|
|
290
293
|
const url = `${this.serverUrl}/v1/txs`;
|
|
@@ -21,7 +21,10 @@ const refCounts = new Map();
|
|
|
21
21
|
*
|
|
22
22
|
* @param dbName The name of the database to open.
|
|
23
23
|
* @param dbVersion The database version to open.
|
|
24
|
-
* @param initDatabase A function that migrates the database schema, called
|
|
24
|
+
* @param initDatabase A function that migrates the database schema, called
|
|
25
|
+
* on `onupgradeneeded` only. Receives the database, the previous version
|
|
26
|
+
* (0 for fresh installs), and the upgrade transaction — the transaction is
|
|
27
|
+
* required for data migrations (cursor/update on existing stores).
|
|
25
28
|
*
|
|
26
29
|
* @returns A promise that resolves to the database instance.
|
|
27
30
|
*/
|
|
@@ -49,9 +52,9 @@ export async function openDatabase(dbName, dbVersion, initDatabase) {
|
|
|
49
52
|
request.onsuccess = () => {
|
|
50
53
|
resolve(request.result);
|
|
51
54
|
};
|
|
52
|
-
request.onupgradeneeded = () => {
|
|
55
|
+
request.onupgradeneeded = (event) => {
|
|
53
56
|
const db = request.result;
|
|
54
|
-
initDatabase(db);
|
|
57
|
+
initDatabase(db, event.oldVersion, request.transaction);
|
|
55
58
|
};
|
|
56
59
|
request.onblocked = () => {
|
|
57
60
|
console.warn("Database upgrade blocked - close other tabs/connections");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { scriptFromArkAddress } from '../scriptFromAddress.js';
|
|
1
2
|
// Store names introduced in V2, they are all new to the migration
|
|
2
3
|
export const STORE_VTXOS = "vtxos";
|
|
3
4
|
export const STORE_UTXOS = "utxos";
|
|
@@ -6,8 +7,15 @@ export const STORE_WALLET_STATE = "walletState";
|
|
|
6
7
|
export const STORE_CONTRACTS = "contracts";
|
|
7
8
|
// @deprecated use only for migrations, this is created in V1
|
|
8
9
|
export const LEGACY_STORE_CONTRACT_COLLECTIONS = "contractsCollections";
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
// Version history:
|
|
11
|
+
// v1 — initial wallet repo schema, `contractsCollections` store.
|
|
12
|
+
// v2 — new `vtxos/utxos/transactions/walletState/contracts` stores.
|
|
13
|
+
// v3 — add `script` index on the vtxos store and backfill missing
|
|
14
|
+
// `vtxo.script` from `vtxo.address` so the field is always present
|
|
15
|
+
// at read time. Matches the `script` indexing already in place for
|
|
16
|
+
// Realm (`realm/schemas.ts`) and SQLite (`sqlite/walletRepository.ts`).
|
|
17
|
+
export const DB_VERSION = 3;
|
|
18
|
+
export function initDatabase(db, oldVersion, transaction) {
|
|
11
19
|
// Create wallet stores
|
|
12
20
|
if (!db.objectStoreNames.contains(STORE_VTXOS)) {
|
|
13
21
|
const vtxosStore = db.createObjectStore(STORE_VTXOS, {
|
|
@@ -64,6 +72,11 @@ export function initDatabase(db) {
|
|
|
64
72
|
unique: false,
|
|
65
73
|
});
|
|
66
74
|
}
|
|
75
|
+
if (!vtxosStore.indexNames.contains("script")) {
|
|
76
|
+
vtxosStore.createIndex("script", "script", {
|
|
77
|
+
unique: false,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
67
80
|
}
|
|
68
81
|
if (!db.objectStoreNames.contains(STORE_UTXOS)) {
|
|
69
82
|
const utxosStore = db.createObjectStore(STORE_UTXOS, {
|
|
@@ -152,4 +165,35 @@ export function initDatabase(db) {
|
|
|
152
165
|
keyPath: "key",
|
|
153
166
|
});
|
|
154
167
|
}
|
|
168
|
+
// v2 → v3: add the `script` index on the existing vtxos store and
|
|
169
|
+
// backfill missing `script` on legacy VTXO rows. The upgrade transaction
|
|
170
|
+
// is null only on a brand-new database (oldVersion === 0), where no
|
|
171
|
+
// legacy rows exist. `createIndex` scans existing records; rows still
|
|
172
|
+
// missing `script` are skipped and get indexed automatically when the
|
|
173
|
+
// backfill's `cursor.update()` adds the field.
|
|
174
|
+
if (oldVersion >= 1 && oldVersion < 3 && transaction) {
|
|
175
|
+
const vtxosStore = transaction.objectStore(STORE_VTXOS);
|
|
176
|
+
if (!vtxosStore.indexNames.contains("script")) {
|
|
177
|
+
vtxosStore.createIndex("script", "script", { unique: false });
|
|
178
|
+
}
|
|
179
|
+
backfillVtxoScripts(transaction);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Exported for unit tests — the `onupgradeneeded` transaction can't be
|
|
183
|
+
// forged in-process, so tests exercise the cursor logic with a regular
|
|
184
|
+
// readwrite transaction on a live DB.
|
|
185
|
+
export function backfillVtxoScripts(transaction) {
|
|
186
|
+
const store = transaction.objectStore(STORE_VTXOS);
|
|
187
|
+
const cursorRequest = store.openCursor();
|
|
188
|
+
cursorRequest.onsuccess = () => {
|
|
189
|
+
const cursor = cursorRequest.result;
|
|
190
|
+
if (!cursor)
|
|
191
|
+
return;
|
|
192
|
+
const value = cursor.value;
|
|
193
|
+
if (!value.script) {
|
|
194
|
+
value.script = scriptFromArkAddress(value.address);
|
|
195
|
+
cursor.update(value);
|
|
196
|
+
}
|
|
197
|
+
cursor.continue();
|
|
198
|
+
};
|
|
155
199
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { STORE_VTXOS, STORE_UTXOS, STORE_TRANSACTIONS, STORE_WALLET_STATE, serializeVtxo, serializeUtxo, deserializeVtxo, deserializeUtxo, DB_VERSION, } from './db.js';
|
|
2
2
|
import { closeDatabase, openDatabase } from './manager.js';
|
|
3
3
|
import { initDatabase } from './schema.js';
|
|
4
|
+
import { scriptFromArkAddress } from '../scriptFromAddress.js';
|
|
4
5
|
import { DEFAULT_DB_NAME } from '../../worker/browser/utils.js';
|
|
5
6
|
/**
|
|
6
7
|
* IndexedDB-based implementation of WalletRepository.
|
|
@@ -66,8 +67,17 @@ export class IndexedDBWalletRepository {
|
|
|
66
67
|
request.onerror = () => reject(request.error);
|
|
67
68
|
request.onsuccess = () => {
|
|
68
69
|
const results = request.result || [];
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
// Wrap `.map` in try/catch so a bad row (e.g. a legacy
|
|
71
|
+
// VTXO whose address can't be decoded during backfill)
|
|
72
|
+
// rejects the promise instead of silently throwing
|
|
73
|
+
// inside the IDB event handler, which would otherwise
|
|
74
|
+
// hang the caller.
|
|
75
|
+
try {
|
|
76
|
+
resolve(results.map(deserializeVtxoWithBackfill));
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
reject(err);
|
|
80
|
+
}
|
|
71
81
|
};
|
|
72
82
|
});
|
|
73
83
|
}
|
|
@@ -332,3 +342,12 @@ export class IndexedDBWalletRepository {
|
|
|
332
342
|
return this.db;
|
|
333
343
|
}
|
|
334
344
|
}
|
|
345
|
+
// Post-migration every row has `script`, but the backfill is idempotent: if a
|
|
346
|
+
// legacy row is ever read before the upgrade-path completes, derive `script`
|
|
347
|
+
// from `address` the same way the indexer would have populated it.
|
|
348
|
+
function deserializeVtxoWithBackfill(o) {
|
|
349
|
+
if (!o.script) {
|
|
350
|
+
o = { ...o, script: scriptFromArkAddress(o.address) };
|
|
351
|
+
}
|
|
352
|
+
return deserializeVtxo(o);
|
|
353
|
+
}
|
|
@@ -54,7 +54,6 @@ export class RealmContractRepository {
|
|
|
54
54
|
state: contract.state,
|
|
55
55
|
paramsJson: JSON.stringify(contract.params),
|
|
56
56
|
createdAt: contract.createdAt,
|
|
57
|
-
expiresAt: contract.expiresAt ?? null,
|
|
58
57
|
label: contract.label ?? null,
|
|
59
58
|
metadataJson: contract.metadata
|
|
60
59
|
? JSON.stringify(contract.metadata)
|
|
@@ -103,9 +102,6 @@ function contractObjectToDomain(obj) {
|
|
|
103
102
|
params: JSON.parse(obj.paramsJson),
|
|
104
103
|
createdAt: obj.createdAt,
|
|
105
104
|
};
|
|
106
|
-
if (obj.expiresAt !== null && obj.expiresAt !== undefined) {
|
|
107
|
-
contract.expiresAt = obj.expiresAt;
|
|
108
|
-
}
|
|
109
105
|
if (obj.label !== null && obj.label !== undefined) {
|
|
110
106
|
contract.label = obj.label;
|
|
111
107
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { RealmWalletRepository } from './walletRepository.js';
|
|
2
2
|
export { RealmContractRepository } from './contractRepository.js';
|
|
3
|
-
export { ArkRealmSchemas } from './schemas.js';
|
|
3
|
+
export { ArkRealmSchemas, ARK_REALM_SCHEMA_VERSION, runArkRealmMigrations, } from './schemas.js';
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* schemas are defined as plain JS objects conforming to Realm's
|
|
9
9
|
* ObjectSchema shape.
|
|
10
10
|
*/
|
|
11
|
+
import { scriptFromArkAddress } from '../scriptFromAddress.js';
|
|
11
12
|
export const ArkVtxoSchema = {
|
|
12
13
|
name: "ArkVtxo",
|
|
13
14
|
primaryKey: "pk",
|
|
@@ -32,6 +33,11 @@ export const ArkVtxoSchema = {
|
|
|
32
33
|
isUnrolled: "bool",
|
|
33
34
|
isSpent: "bool?",
|
|
34
35
|
assetsJson: "string?",
|
|
36
|
+
// scriptPubKey (hex) locking this VTXO, indexed so contract-scoped
|
|
37
|
+
// queries can resolve ownership without touching address mapping.
|
|
38
|
+
// Required as of schema v2; legacy rows are backfilled from `address`
|
|
39
|
+
// during migration (see `runArkRealmMigrations`).
|
|
40
|
+
script: { type: "string", indexed: true },
|
|
35
41
|
},
|
|
36
42
|
};
|
|
37
43
|
export const ArkUtxoSchema = {
|
|
@@ -103,3 +109,45 @@ export const ArkRealmSchemas = [
|
|
|
103
109
|
ArkWalletStateSchema,
|
|
104
110
|
ArkContractSchema,
|
|
105
111
|
];
|
|
112
|
+
/**
|
|
113
|
+
* Current Realm schema version for the Arkade wallet.
|
|
114
|
+
*
|
|
115
|
+
* Consumers opening Realm must pass a `schemaVersion` at least this high so
|
|
116
|
+
* legacy databases get migrated; merge it with your own app's version:
|
|
117
|
+
*
|
|
118
|
+
* ```ts
|
|
119
|
+
* await Realm.open({
|
|
120
|
+
* schema: [...ArkRealmSchemas, ...yourSchemas],
|
|
121
|
+
* schemaVersion: Math.max(ARK_REALM_SCHEMA_VERSION, yourSchemaVersion),
|
|
122
|
+
* onMigration: (oldRealm, newRealm) => {
|
|
123
|
+
* runArkRealmMigrations(oldRealm, newRealm);
|
|
124
|
+
* // your own migrations
|
|
125
|
+
* },
|
|
126
|
+
* });
|
|
127
|
+
* ```
|
|
128
|
+
*
|
|
129
|
+
* History:
|
|
130
|
+
* - v1: initial ArkVtxo/ArkUtxo/... schemas, `script` nullable.
|
|
131
|
+
* - v2: ArkVtxo.script becomes required; NULL values are backfilled from
|
|
132
|
+
* the owning Ark address during migration.
|
|
133
|
+
*/
|
|
134
|
+
export const ARK_REALM_SCHEMA_VERSION = 2;
|
|
135
|
+
/**
|
|
136
|
+
* Run every Arkade schema migration applicable to the open Realm.
|
|
137
|
+
*
|
|
138
|
+
* Designed to be composed with the consumer's own migrations inside a single
|
|
139
|
+
* `onMigration` callback. Each migration step does a per-row check so it
|
|
140
|
+
* remains idempotent and independent of the app's global `schemaVersion` —
|
|
141
|
+
* a consumer whose app is already at version 10 can still trigger the
|
|
142
|
+
* Arkade v1→v2 script backfill when the row has never been populated.
|
|
143
|
+
*/
|
|
144
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
145
|
+
export function runArkRealmMigrations(oldRealm, newRealm) {
|
|
146
|
+
const newVtxos = newRealm.objects("ArkVtxo");
|
|
147
|
+
for (let i = 0; i < newVtxos.length; i++) {
|
|
148
|
+
const newVtxo = newVtxos[i];
|
|
149
|
+
if (!newVtxo.script) {
|
|
150
|
+
newVtxo.script = scriptFromArkAddress(newVtxo.address);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { serializeVtxo, serializeUtxo, deserializeVtxo, deserializeUtxo, } from '../serialization.js';
|
|
2
|
+
import { scriptFromArkAddress } from '../scriptFromAddress.js';
|
|
2
3
|
/**
|
|
3
4
|
* Realm-based implementation of WalletRepository.
|
|
4
5
|
*
|
|
@@ -70,6 +71,7 @@ export class RealmWalletRepository {
|
|
|
70
71
|
? JSON.stringify(s.extraWitness)
|
|
71
72
|
: null,
|
|
72
73
|
assetsJson: s.assets ? JSON.stringify(s.assets) : null,
|
|
74
|
+
script: s.script ?? null,
|
|
73
75
|
}, "modified");
|
|
74
76
|
}
|
|
75
77
|
});
|
|
@@ -175,12 +177,10 @@ export class RealmWalletRepository {
|
|
|
175
177
|
return null;
|
|
176
178
|
const obj = items[0];
|
|
177
179
|
const state = {};
|
|
178
|
-
if (obj.lastSyncTime !== null && obj.lastSyncTime !== undefined) {
|
|
179
|
-
state.lastSyncTime = obj.lastSyncTime;
|
|
180
|
-
}
|
|
181
180
|
if (obj.settingsJson) {
|
|
182
181
|
state.settings = JSON.parse(obj.settingsJson);
|
|
183
182
|
}
|
|
183
|
+
state.lastSyncTime = obj.lastSyncTime ?? undefined;
|
|
184
184
|
return state;
|
|
185
185
|
}
|
|
186
186
|
async saveWalletState(state) {
|
|
@@ -188,7 +188,7 @@ export class RealmWalletRepository {
|
|
|
188
188
|
this.realm.write(() => {
|
|
189
189
|
this.realm.create("ArkWalletState", {
|
|
190
190
|
key: "state",
|
|
191
|
-
lastSyncTime: state.lastSyncTime
|
|
191
|
+
lastSyncTime: state.lastSyncTime,
|
|
192
192
|
settingsJson: state.settings
|
|
193
193
|
? JSON.stringify(state.settings)
|
|
194
194
|
: null,
|
|
@@ -224,6 +224,10 @@ function vtxoObjectToDomain(obj) {
|
|
|
224
224
|
? JSON.parse(obj.extraWitnessJson)
|
|
225
225
|
: undefined,
|
|
226
226
|
assets: obj.assetsJson ? JSON.parse(obj.assetsJson) : undefined,
|
|
227
|
+
// Post-migration every row has `script`, but the backfill is
|
|
228
|
+
// idempotent: derive from `address` if the legacy column is still
|
|
229
|
+
// null (e.g. the migration hasn't run yet on this handle).
|
|
230
|
+
script: obj.script ?? scriptFromArkAddress(obj.address),
|
|
227
231
|
};
|
|
228
232
|
return deserializeVtxo(serialized);
|
|
229
233
|
}
|