@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.
Files changed (70) hide show
  1. package/README.md +16 -6
  2. package/dist/cjs/contracts/arkcontract.js +0 -2
  3. package/dist/cjs/contracts/contractManager.js +111 -215
  4. package/dist/cjs/contracts/contractWatcher.js +86 -115
  5. package/dist/cjs/providers/ark.js +36 -33
  6. package/dist/cjs/repositories/indexedDB/manager.js +6 -3
  7. package/dist/cjs/repositories/indexedDB/schema.js +47 -2
  8. package/dist/cjs/repositories/indexedDB/walletRepository.js +21 -2
  9. package/dist/cjs/repositories/realm/contractRepository.js +0 -4
  10. package/dist/cjs/repositories/realm/index.js +3 -1
  11. package/dist/cjs/repositories/realm/schemas.js +50 -1
  12. package/dist/cjs/repositories/realm/walletRepository.js +8 -4
  13. package/dist/cjs/repositories/scriptFromAddress.js +16 -0
  14. package/dist/cjs/repositories/sqlite/contractRepository.js +2 -6
  15. package/dist/cjs/repositories/sqlite/walletRepository.js +121 -33
  16. package/dist/cjs/utils/syncCursors.js +48 -56
  17. package/dist/cjs/wallet/expo/background.js +0 -13
  18. package/dist/cjs/wallet/expo/wallet.js +1 -6
  19. package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +16 -7
  20. package/dist/cjs/wallet/serviceWorker/wallet.js +19 -0
  21. package/dist/cjs/wallet/utils.js +41 -10
  22. package/dist/cjs/wallet/vtxo-manager.js +222 -40
  23. package/dist/cjs/wallet/wallet.js +149 -211
  24. package/dist/cjs/worker/expo/processors/contractPollProcessor.js +9 -13
  25. package/dist/cjs/worker/expo/taskRunner.js +2 -11
  26. package/dist/esm/contracts/arkcontract.js +0 -2
  27. package/dist/esm/contracts/contractManager.js +113 -217
  28. package/dist/esm/contracts/contractWatcher.js +86 -115
  29. package/dist/esm/providers/ark.js +36 -33
  30. package/dist/esm/repositories/indexedDB/manager.js +6 -3
  31. package/dist/esm/repositories/indexedDB/schema.js +46 -2
  32. package/dist/esm/repositories/indexedDB/walletRepository.js +21 -2
  33. package/dist/esm/repositories/realm/contractRepository.js +0 -4
  34. package/dist/esm/repositories/realm/index.js +1 -1
  35. package/dist/esm/repositories/realm/schemas.js +48 -0
  36. package/dist/esm/repositories/realm/walletRepository.js +8 -4
  37. package/dist/esm/repositories/scriptFromAddress.js +13 -0
  38. package/dist/esm/repositories/sqlite/contractRepository.js +2 -6
  39. package/dist/esm/repositories/sqlite/walletRepository.js +121 -33
  40. package/dist/esm/utils/syncCursors.js +47 -53
  41. package/dist/esm/wallet/expo/background.js +0 -13
  42. package/dist/esm/wallet/expo/wallet.js +2 -7
  43. package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +17 -8
  44. package/dist/esm/wallet/serviceWorker/wallet.js +19 -0
  45. package/dist/esm/wallet/utils.js +41 -9
  46. package/dist/esm/wallet/vtxo-manager.js +222 -40
  47. package/dist/esm/wallet/wallet.js +152 -214
  48. package/dist/esm/worker/expo/processors/contractPollProcessor.js +9 -13
  49. package/dist/esm/worker/expo/taskRunner.js +3 -12
  50. package/dist/types/contracts/arkcontract.d.ts +0 -2
  51. package/dist/types/contracts/contractManager.d.ts +38 -9
  52. package/dist/types/contracts/contractWatcher.d.ts +22 -21
  53. package/dist/types/contracts/types.d.ts +0 -7
  54. package/dist/types/repositories/indexedDB/manager.d.ts +5 -2
  55. package/dist/types/repositories/indexedDB/schema.d.ts +3 -2
  56. package/dist/types/repositories/realm/index.d.ts +1 -1
  57. package/dist/types/repositories/realm/schemas.d.ts +41 -0
  58. package/dist/types/repositories/scriptFromAddress.d.ts +9 -0
  59. package/dist/types/repositories/serialization.d.ts +1 -1
  60. package/dist/types/repositories/sqlite/walletRepository.d.ts +22 -0
  61. package/dist/types/repositories/walletRepository.d.ts +10 -2
  62. package/dist/types/utils/syncCursors.d.ts +25 -23
  63. package/dist/types/wallet/index.d.ts +1 -1
  64. package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +15 -3
  65. package/dist/types/wallet/utils.d.ts +20 -4
  66. package/dist/types/wallet/vtxo-manager.d.ts +29 -6
  67. package/dist/types/wallet/wallet.d.ts +8 -17
  68. package/dist/types/worker/expo/processors/contractPollProcessor.d.ts +9 -4
  69. package/dist/types/worker/expo/taskRunner.d.ts +6 -3
  70. 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
- * Get all active in-memory contracts.
106
- */
107
- getActiveContracts() {
108
- return this.getAllContracts().filter((c) => c.state === "active");
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
- * Returns scripts for:
114
- * - All active contracts
115
- * - All contracts with known virtual outputs (regardless of state)
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
- getScriptsToWatch() {
121
- const scripts = new Set();
122
- for (const [, state] of this.contracts) {
123
- // Always watch active contracts
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 activeScripts = this.getActiveContracts().map((c) => c.script);
337
- if (activeScripts.length === 0)
327
+ const scripts = this.getWatchedContracts().map((c) => c.script);
328
+ if (scripts.length === 0)
338
329
  return;
339
- await this.pollContracts(activeScripts);
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.getScriptsToWatch();
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, scripts, "vtxo_received", timestamp);
464
+ this.processSubscriptionVtxos(update.newVtxos, "vtxo_received", timestamp);
475
465
  }
476
466
  if (update.spentVtxos?.length) {
477
- this.processSubscriptionVtxos(update.spentVtxos, scripts, "vtxo_spent", timestamp);
467
+ this.processSubscriptionVtxos(update.spentVtxos, "vtxo_spent", timestamp);
478
468
  }
479
469
  }
480
470
  /**
481
- * Process virtual outputs from subscription and route to correct contracts.
482
- * Uses the scripts from the subscription response to determine contract ownership.
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, scripts, eventType, timestamp) {
485
- // If we have exactly one script, all virtual outputs belong to that contract
486
- // Otherwise, we can't reliably determine ownership without script in VirtualCoin
487
- if (scripts.length === 1) {
488
- const contractScript = scripts[0];
489
- if (contractScript) {
490
- // Update tracking
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
- return;
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
- // Multiple scripts - assign virtual outputs to all matching contracts
508
- // This is a limitation: we can't know which virtual output belongs to which script
509
- // In practice, subscription events usually come with a single script context
510
- for (const script of scripts) {
511
- const contractScript = script;
512
- if (contractScript) {
513
- const state = this.contracts.get(contractScript);
514
- if (state) {
515
- for (const vtxo of vtxos) {
516
- const key = `${vtxo.txid}:${vtxo.vout}`;
517
- if (eventType === "vtxo_received") {
518
- state.lastKnownVtxos.set(key, vtxo);
519
- }
520
- else {
521
- state.lastKnownVtxos.delete(key);
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
- // ensure we check somehow regularly
537
- this.checkExpiredContracts();
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
- // Create first EventSource eagerly so events are buffered
231
- // before the caller starts iterating, preventing race conditions
232
- // where the server emits events before iteration begins.
233
- const eagerEventSource = new EventSource(url + queryParams);
234
- const eagerIterator = eventSourceIterator(eagerEventSource);
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
- return (async function* () {
238
- let firstIteration = true;
239
- while (!signal?.aborted) {
240
- const eventSource = firstIteration
241
- ? eagerEventSource
242
- : new EventSource(url + queryParams);
243
- const iterator = firstIteration
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
- catch (error) {
275
- if (error instanceof Error && error.name === "AbortError") {
276
- break;
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 on `onupgradeneeded` only.
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
- export const DB_VERSION = 2;
10
- export function initDatabase(db) {
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
- const vtxos = results.map(deserializeVtxo);
70
- resolve(vtxos);
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 ?? null,
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
  }