@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
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RealmWalletRepository = void 0;
4
4
  const serialization_1 = require("../serialization");
5
+ const scriptFromAddress_1 = require("../scriptFromAddress");
5
6
  /**
6
7
  * Realm-based implementation of WalletRepository.
7
8
  *
@@ -73,6 +74,7 @@ class RealmWalletRepository {
73
74
  ? JSON.stringify(s.extraWitness)
74
75
  : null,
75
76
  assetsJson: s.assets ? JSON.stringify(s.assets) : null,
77
+ script: s.script ?? null,
76
78
  }, "modified");
77
79
  }
78
80
  });
@@ -178,12 +180,10 @@ class RealmWalletRepository {
178
180
  return null;
179
181
  const obj = items[0];
180
182
  const state = {};
181
- if (obj.lastSyncTime !== null && obj.lastSyncTime !== undefined) {
182
- state.lastSyncTime = obj.lastSyncTime;
183
- }
184
183
  if (obj.settingsJson) {
185
184
  state.settings = JSON.parse(obj.settingsJson);
186
185
  }
186
+ state.lastSyncTime = obj.lastSyncTime ?? undefined;
187
187
  return state;
188
188
  }
189
189
  async saveWalletState(state) {
@@ -191,7 +191,7 @@ class RealmWalletRepository {
191
191
  this.realm.write(() => {
192
192
  this.realm.create("ArkWalletState", {
193
193
  key: "state",
194
- lastSyncTime: state.lastSyncTime ?? null,
194
+ lastSyncTime: state.lastSyncTime,
195
195
  settingsJson: state.settings
196
196
  ? JSON.stringify(state.settings)
197
197
  : null,
@@ -228,6 +228,10 @@ function vtxoObjectToDomain(obj) {
228
228
  ? JSON.parse(obj.extraWitnessJson)
229
229
  : undefined,
230
230
  assets: obj.assetsJson ? JSON.parse(obj.assetsJson) : undefined,
231
+ // Post-migration every row has `script`, but the backfill is
232
+ // idempotent: derive from `address` if the legacy column is still
233
+ // null (e.g. the migration hasn't run yet on this handle).
234
+ script: obj.script ?? (0, scriptFromAddress_1.scriptFromArkAddress)(obj.address),
231
235
  };
232
236
  return (0, serialization_1.deserializeVtxo)(serialized);
233
237
  }
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scriptFromArkAddress = scriptFromArkAddress;
4
+ const base_1 = require("@scure/base");
5
+ const index_1 = require("../index");
6
+ /**
7
+ * Compute the hex-encoded `scriptPubKey` locking a VTXO from its owning Ark
8
+ * address. Used by repository-layer migrations to backfill `script` on legacy
9
+ * rows that pre-date the column (the indexer now guarantees the field, so new
10
+ * rows never go through this path). The `script` field is required by the
11
+ * domain type, so backfill must produce the same value the indexer would
12
+ * have returned — which is the hex of the address's `pkScript`.
13
+ */
14
+ function scriptFromArkAddress(address) {
15
+ return base_1.hex.encode(index_1.ArkAddress.decode(address).pkScript);
16
+ }
@@ -71,16 +71,15 @@ class SQLiteContractRepository {
71
71
  await this.ensureInit();
72
72
  await this.db.run(`INSERT OR REPLACE INTO ${this.table}
73
73
  (script, address, type, state, params_json,
74
- created_at, expires_at, label, metadata_json)
74
+ created_at, label, metadata_json)
75
75
  VALUES (?, ?, ?, ?, ?,
76
- ?, ?, ?, ?)`, [
76
+ ?, ?, ?)`, [
77
77
  contract.script,
78
78
  contract.address,
79
79
  contract.type,
80
80
  contract.state,
81
81
  JSON.stringify(contract.params),
82
82
  contract.createdAt,
83
- contract.expiresAt ?? null,
84
83
  contract.label ?? null,
85
84
  contract.metadata ? JSON.stringify(contract.metadata) : null,
86
85
  ]);
@@ -126,9 +125,6 @@ function contractRowToDomain(row) {
126
125
  params: JSON.parse(row.params_json),
127
126
  createdAt: row.created_at,
128
127
  };
129
- if (row.expires_at !== null) {
130
- contract.expiresAt = row.expires_at;
131
- }
132
128
  if (row.label !== null) {
133
129
  contract.label = row.label;
134
130
  }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SQLiteWalletRepository = void 0;
4
4
  const serialization_1 = require("../serialization");
5
+ const scriptFromAddress_1 = require("../scriptFromAddress");
5
6
  /**
6
7
  * SQLite-based implementation of WalletRepository.
7
8
  *
@@ -32,30 +33,7 @@ class SQLiteWalletRepository {
32
33
  return this.initPromise;
33
34
  }
34
35
  async init() {
35
- await this.db.run(`
36
- CREATE TABLE IF NOT EXISTS ${this.tables.vtxos} (
37
- txid TEXT NOT NULL,
38
- vout INTEGER NOT NULL,
39
- value INTEGER NOT NULL,
40
- address TEXT NOT NULL,
41
- tap_tree TEXT NOT NULL,
42
- forfeit_cb TEXT NOT NULL,
43
- forfeit_s TEXT NOT NULL,
44
- intent_cb TEXT NOT NULL,
45
- intent_s TEXT NOT NULL,
46
- status_json TEXT NOT NULL,
47
- virtual_status_json TEXT NOT NULL,
48
- created_at TEXT NOT NULL,
49
- is_unrolled INTEGER NOT NULL DEFAULT 0,
50
- is_spent INTEGER,
51
- spent_by TEXT,
52
- settled_by TEXT,
53
- ark_tx_id TEXT,
54
- extra_witness_json TEXT,
55
- assets_json TEXT,
56
- PRIMARY KEY (txid, vout)
57
- )
58
- `);
36
+ await this.migrateVtxosTable();
59
37
  await this.db.run(`
60
38
  CREATE TABLE IF NOT EXISTS ${this.tables.utxos} (
61
39
  txid TEXT NOT NULL,
@@ -89,14 +67,121 @@ class SQLiteWalletRepository {
89
67
  await this.db.run(`
90
68
  CREATE TABLE IF NOT EXISTS ${this.tables.walletState} (
91
69
  key TEXT PRIMARY KEY,
92
- last_sync_time INTEGER,
93
- settings_json TEXT
70
+ settings_json TEXT,
71
+ last_sync_time INTEGER
94
72
  )
95
73
  `);
96
74
  await this.db.run(`CREATE INDEX IF NOT EXISTS idx_${this.prefix}vtxos_address ON ${this.tables.vtxos} (address)`);
75
+ await this.db.run(`CREATE INDEX IF NOT EXISTS idx_${this.prefix}vtxos_script ON ${this.tables.vtxos} (script)`);
97
76
  await this.db.run(`CREATE INDEX IF NOT EXISTS idx_${this.prefix}utxos_address ON ${this.tables.utxos} (address)`);
98
77
  await this.db.run(`CREATE INDEX IF NOT EXISTS idx_${this.prefix}transactions_address ON ${this.tables.transactions} (address)`);
99
78
  }
79
+ /**
80
+ * Bring the `vtxos` table to the current schema (v1 = `script` NOT NULL).
81
+ *
82
+ * Three cases:
83
+ * - Fresh install: create the v1 schema directly.
84
+ * - Legacy install without a `script` column: add it, backfill from
85
+ * `address`, then rebuild the table with NOT NULL (SQLite cannot add
86
+ * the NOT NULL constraint in place).
87
+ * - Legacy install with a nullable `script` column: backfill the NULLs
88
+ * and rebuild.
89
+ *
90
+ * The backfill derives `script` from the Ark address, matching what the
91
+ * indexer would have returned — new rows from the indexer always carry a
92
+ * populated `script`, so the migration is idempotent.
93
+ *
94
+ * The rebuild path is wrapped in a transaction: without it, a crash
95
+ * between the `DROP TABLE vtxos` and the `RENAME tmp → vtxos` commits
96
+ * would leave the next startup seeing no `vtxos` table and create a
97
+ * fresh empty one, silently orphaning every row in the temp table.
98
+ */
99
+ async migrateVtxosTable() {
100
+ const tableExists = await this.db.get(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, [this.tables.vtxos]);
101
+ if (!tableExists) {
102
+ await this.db.run(this.vtxosCreateSql(this.tables.vtxos));
103
+ return;
104
+ }
105
+ const cols = await this.db.all(`PRAGMA table_info(${this.tables.vtxos})`);
106
+ const scriptCol = cols.find((c) => c.name === "script");
107
+ if (scriptCol && scriptCol.notnull === 1) {
108
+ // Already on v1 schema.
109
+ return;
110
+ }
111
+ await this.db.run("BEGIN IMMEDIATE");
112
+ try {
113
+ if (!scriptCol) {
114
+ await this.db.run(`ALTER TABLE ${this.tables.vtxos} ADD COLUMN script TEXT`);
115
+ }
116
+ // Backfill any NULL scripts from the owning address. Derivation
117
+ // is deterministic, so re-running after a rolled-back attempt
118
+ // produces the same values.
119
+ const nullRows = await this.db.all(`SELECT txid, vout, address FROM ${this.tables.vtxos} WHERE script IS NULL`);
120
+ for (const row of nullRows) {
121
+ await this.db.run(`UPDATE ${this.tables.vtxos} SET script = ? WHERE txid = ? AND vout = ?`, [(0, scriptFromAddress_1.scriptFromArkAddress)(row.address), row.txid, row.vout]);
122
+ }
123
+ // SQLite can't turn a nullable column into NOT NULL in place —
124
+ // do the canonical 12-step table rebuild. Drop any stale temp
125
+ // table from a prior aborted attempt first.
126
+ const tempName = `${this.tables.vtxos}__migrate_tmp`;
127
+ await this.db.run(`DROP TABLE IF EXISTS ${tempName}`);
128
+ await this.db.run(this.vtxosCreateSql(tempName));
129
+ await this.db.run(`
130
+ INSERT INTO ${tempName}
131
+ (txid, vout, value, address, tap_tree,
132
+ forfeit_cb, forfeit_s, intent_cb, intent_s,
133
+ status_json, virtual_status_json, created_at,
134
+ is_unrolled, is_spent, spent_by, settled_by, ark_tx_id,
135
+ extra_witness_json, assets_json, script)
136
+ SELECT txid, vout, value, address, tap_tree,
137
+ forfeit_cb, forfeit_s, intent_cb, intent_s,
138
+ status_json, virtual_status_json, created_at,
139
+ is_unrolled, is_spent, spent_by, settled_by, ark_tx_id,
140
+ extra_witness_json, assets_json, script
141
+ FROM ${this.tables.vtxos}
142
+ `);
143
+ await this.db.run(`DROP TABLE ${this.tables.vtxos}`);
144
+ await this.db.run(`ALTER TABLE ${tempName} RENAME TO ${this.tables.vtxos}`);
145
+ await this.db.run("COMMIT");
146
+ }
147
+ catch (e) {
148
+ // A failed COMMIT auto-rolls-back in SQLite, which makes a
149
+ // follow-up ROLLBACK throw "no transaction is active". Swallow
150
+ // that secondary error so the original cause surfaces.
151
+ try {
152
+ await this.db.run("ROLLBACK");
153
+ }
154
+ catch {
155
+ // already rolled back
156
+ }
157
+ throw e;
158
+ }
159
+ }
160
+ vtxosCreateSql(tableName) {
161
+ return `CREATE TABLE ${tableName} (
162
+ txid TEXT NOT NULL,
163
+ vout INTEGER NOT NULL,
164
+ value INTEGER NOT NULL,
165
+ address TEXT NOT NULL,
166
+ tap_tree TEXT NOT NULL,
167
+ forfeit_cb TEXT NOT NULL,
168
+ forfeit_s TEXT NOT NULL,
169
+ intent_cb TEXT NOT NULL,
170
+ intent_s TEXT NOT NULL,
171
+ status_json TEXT NOT NULL,
172
+ virtual_status_json TEXT NOT NULL,
173
+ created_at TEXT NOT NULL,
174
+ is_unrolled INTEGER NOT NULL DEFAULT 0,
175
+ is_spent INTEGER,
176
+ spent_by TEXT,
177
+ settled_by TEXT,
178
+ ark_tx_id TEXT,
179
+ extra_witness_json TEXT,
180
+ assets_json TEXT,
181
+ script TEXT NOT NULL,
182
+ PRIMARY KEY (txid, vout)
183
+ )`;
184
+ }
100
185
  async [Symbol.asyncDispose]() {
101
186
  // no-op — consumer owns the SQLExecutor lifecycle
102
187
  }
@@ -123,12 +208,12 @@ class SQLiteWalletRepository {
123
208
  tap_tree, forfeit_cb, forfeit_s, intent_cb, intent_s,
124
209
  status_json, virtual_status_json, created_at,
125
210
  is_unrolled, is_spent, spent_by, settled_by, ark_tx_id,
126
- extra_witness_json, assets_json)
211
+ extra_witness_json, assets_json, script)
127
212
  VALUES (?, ?, ?, ?,
128
213
  ?, ?, ?, ?, ?,
129
214
  ?, ?, ?,
130
215
  ?, ?, ?, ?, ?,
131
- ?, ?)`, [
216
+ ?, ?, ?)`, [
132
217
  s.txid,
133
218
  s.vout,
134
219
  s.value,
@@ -152,6 +237,7 @@ class SQLiteWalletRepository {
152
237
  s.arkTxId ?? null,
153
238
  s.extraWitness ? JSON.stringify(s.extraWitness) : null,
154
239
  s.assets ? JSON.stringify(s.assets) : null,
240
+ s.script ?? null,
155
241
  ]);
156
242
  }
157
243
  }
@@ -231,22 +317,20 @@ class SQLiteWalletRepository {
231
317
  if (!row)
232
318
  return null;
233
319
  const state = {};
234
- if (row.last_sync_time !== null && row.last_sync_time !== undefined) {
235
- state.lastSyncTime = row.last_sync_time;
236
- }
237
320
  if (row.settings_json) {
238
321
  state.settings = JSON.parse(row.settings_json);
239
322
  }
323
+ state.lastSyncTime = row.last_sync_time ?? undefined;
240
324
  return state;
241
325
  }
242
326
  async saveWalletState(state) {
243
327
  await this.ensureInit();
244
328
  await this.db.run(`INSERT OR REPLACE INTO ${this.tables.walletState}
245
- (key, last_sync_time, settings_json)
329
+ (key, settings_json, last_sync_time)
246
330
  VALUES (?, ?, ?)`, [
247
331
  "state",
248
- state.lastSyncTime ?? null,
249
332
  state.settings ? JSON.stringify(state.settings) : null,
333
+ state.lastSyncTime ?? null,
250
334
  ]);
251
335
  }
252
336
  }
@@ -285,6 +369,10 @@ function vtxoRowToDomain(row) {
285
369
  ? JSON.parse(row.extra_witness_json)
286
370
  : undefined,
287
371
  assets: row.assets_json ? JSON.parse(row.assets_json) : undefined,
372
+ // Post-migration every row has `script`, but the backfill is
373
+ // idempotent: derive from `address` if the legacy column is still
374
+ // null (e.g. the migration hasn't run yet on this handle).
375
+ script: row.script ?? (0, scriptFromAddress_1.scriptFromArkAddress)(row.address),
288
376
  };
289
377
  return (0, serialization_1.deserializeVtxo)(serialized);
290
378
  }
@@ -3,20 +3,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OVERLAP_MS = exports.SAFETY_LAG_MS = void 0;
4
4
  exports.updateWalletState = updateWalletState;
5
5
  exports.getSyncCursor = getSyncCursor;
6
- exports.getAllSyncCursors = getAllSyncCursors;
7
6
  exports.advanceSyncCursor = advanceSyncCursor;
8
- exports.advanceSyncCursors = advanceSyncCursors;
9
- exports.clearSyncCursors = clearSyncCursors;
7
+ exports.clearSyncCursor = clearSyncCursor;
10
8
  exports.computeSyncWindow = computeSyncWindow;
11
9
  exports.cursorCutoff = cursorCutoff;
12
10
  /** Lag behind real-time to avoid racing with indexer writes. */
13
11
  exports.SAFETY_LAG_MS = 30000;
14
12
  /** Overlap window so boundary virtual outputs are never missed. */
15
- exports.OVERLAP_MS = 60000;
13
+ exports.OVERLAP_MS = 24 * 60 * 60 * 1000;
16
14
  /**
17
15
  * Per-repository mutex that serializes wallet-state mutations so that
18
- * concurrent read-modify-write cycles (e.g. advanceSyncCursors racing
19
- * with clearSyncCursors or setPendingTxFlag) never silently overwrite
16
+ * concurrent read-modify-write cycles never silently overwrite
20
17
  * each other's changes.
21
18
  */
22
19
  const walletStateLocks = new WeakMap();
@@ -36,88 +33,83 @@ async function updateWalletState(repo, updater) {
36
33
  return op;
37
34
  }
38
35
  /**
39
- * Read the high-water mark for a single script.
40
- * Returns `undefined` when the script has never been synced (bootstrap case).
36
+ * Settings key that gates interpretation of the `lastSyncTime` field.
37
+ *
38
+ * The `lastSyncTime` column existed pre-PR with a different semantic
39
+ * (wall-clock at sync completion, written by the buggy sync loop this
40
+ * PR fixes). On upgrade we cannot trust any pre-existing value, so the
41
+ * cursor is only honoured after the first successful post-upgrade
42
+ * advance writes this marker into the `settings` JSON blob. Reusing
43
+ * `settings` avoids any schema migration.
41
44
  */
42
- async function getSyncCursor(repo, script) {
43
- const state = await repo.getWalletState();
44
- return state?.settings?.vtxoSyncCursors?.[script];
45
+ const CURSOR_MIGRATED_KEY = "vtxoCursorMigrated";
46
+ function hasMigrationMarker(state) {
47
+ return state?.settings?.[CURSOR_MIGRATED_KEY] === true;
45
48
  }
46
49
  /**
47
- * Read cursors for every previously-synced script.
50
+ * Read the global high-water mark for VTXO indexer syncs.
51
+ *
52
+ * Returns `0` when:
53
+ * - the wallet has never been synced (bootstrap case), or
54
+ * - the stored `lastSyncTime` was written by pre-PR code and is not
55
+ * safe to reuse under the new semantics (see {@link CURSOR_MIGRATED_KEY}).
48
56
  */
49
- async function getAllSyncCursors(repo) {
57
+ async function getSyncCursor(repo) {
50
58
  const state = await repo.getWalletState();
51
- return state?.settings?.vtxoSyncCursors ?? {};
59
+ if (!hasMigrationMarker(state))
60
+ return 0;
61
+ return state?.lastSyncTime ?? 0;
52
62
  }
53
63
  /**
54
- * Advance the cursor for one script after a successful delta sync.
55
- * `cursor` should be the `before` cutoff used in the request.
56
- */
57
- async function advanceSyncCursor(repo, script, cursor) {
58
- await updateWalletState(repo, (state) => {
59
- const existing = state.settings?.vtxoSyncCursors ?? {};
60
- return {
61
- ...state,
62
- settings: {
63
- ...state.settings,
64
- vtxoSyncCursors: { ...existing, [script]: cursor },
65
- },
66
- };
67
- });
68
- }
69
- /**
70
- * Advance cursors for multiple scripts in a single write.
64
+ * Advance the global cursor after a successful full-scope delta sync.
65
+ *
66
+ * Clamped with `Math.max` against the current value so concurrent syncs
67
+ * that finish out of order can't rewind the cursor: `lastUpdatedAt` is
68
+ * captured before each sync enters the `updateWalletState` mutex, and
69
+ * the later-started sync would otherwise overwrite the earlier-captured
70
+ * one with a smaller value. The legacy value is discarded on the first
71
+ * advance if the migration marker is absent so pre-PR data doesn't
72
+ * survive the upgrade.
71
73
  */
72
- async function advanceSyncCursors(repo, updates) {
74
+ async function advanceSyncCursor(repo, lastUpdatedAt) {
73
75
  await updateWalletState(repo, (state) => {
74
- const existing = state.settings?.vtxoSyncCursors ?? {};
76
+ const current = hasMigrationMarker(state)
77
+ ? (state.lastSyncTime ?? 0)
78
+ : 0;
75
79
  return {
76
80
  ...state,
81
+ lastSyncTime: Math.max(current, lastUpdatedAt),
77
82
  settings: {
78
- ...state.settings,
79
- vtxoSyncCursors: { ...existing, ...updates },
83
+ ...(state.settings ?? {}),
84
+ [CURSOR_MIGRATED_KEY]: true,
80
85
  },
81
86
  };
82
87
  });
83
88
  }
84
89
  /**
85
- * Remove sync cursors, forcing a full re-bootstrap on next sync.
86
- * When `scripts` is provided, only those cursors are cleared.
90
+ * Remove the sync cursor, forcing a full re-bootstrap on next sync.
91
+ *
92
+ * Also clears the migration marker so any stored `lastSyncTime` is
93
+ * treated as untrusted on the next read.
87
94
  */
88
- async function clearSyncCursors(repo, scripts) {
95
+ async function clearSyncCursor(repo) {
89
96
  await updateWalletState(repo, (state) => {
90
- if (!scripts) {
91
- const { vtxoSyncCursors: _, ...restSettings } = state.settings ?? {};
92
- return {
93
- ...state,
94
- settings: restSettings,
95
- };
96
- }
97
- const existing = state.settings?.vtxoSyncCursors ?? {};
98
- const filtered = { ...existing };
99
- for (const s of scripts)
100
- delete filtered[s];
97
+ const { [CURSOR_MIGRATED_KEY]: _, ...restSettings } = state.settings ?? {};
101
98
  return {
102
99
  ...state,
103
- settings: {
104
- ...state.settings,
105
- vtxoSyncCursors: filtered,
106
- },
100
+ lastSyncTime: undefined,
101
+ settings: restSettings,
107
102
  };
108
103
  });
109
104
  }
110
105
  /**
111
106
  * Compute the `after` lower-bound for a delta sync query.
112
- * Returns `undefined` when the script has no cursor (bootstrap needed).
113
107
  *
114
108
  * No upper bound (`before`) is applied to the query so that freshly
115
109
  * created virtual outputs are never excluded. The safety lag is applied only
116
110
  * when advancing the cursor (see @see cursorCutoff).
117
111
  */
118
112
  function computeSyncWindow(cursor) {
119
- if (cursor === undefined)
120
- return undefined;
121
113
  const after = Math.max(0, cursor - exports.OVERLAP_MS);
122
114
  return { after };
123
115
  }
@@ -3,10 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.defineExpoBackgroundTask = defineExpoBackgroundTask;
4
4
  exports.registerExpoBackgroundTask = registerExpoBackgroundTask;
5
5
  exports.unregisterExpoBackgroundTask = unregisterExpoBackgroundTask;
6
- const base_1 = require("@scure/base");
7
6
  const taskRunner_1 = require("../../worker/expo/taskRunner");
8
7
  const processors_1 = require("../../worker/expo/processors");
9
- const default_1 = require("../../script/default");
10
8
  const expoArk_1 = require("../../providers/expoArk");
11
9
  const expoIndexer_1 = require("../../providers/expoIndexer");
12
10
  const utils_1 = require("../utils");
@@ -64,22 +62,11 @@ function defineExpoBackgroundTask(taskName, options) {
64
62
  // Reconstruct providers
65
63
  const indexerProvider = new expoIndexer_1.ExpoIndexerProvider(config.arkServerUrl);
66
64
  const arkProvider = new expoArk_1.ExpoArkProvider(config.arkServerUrl);
67
- // Reconstruct default offchainTapscript as fallback
68
- // for virtual outputs not associated with a contract.
69
- const offchainTapscript = new default_1.DefaultVtxo.Script({
70
- pubKey: base_1.hex.decode(config.pubkeyHex),
71
- serverPubKey: base_1.hex.decode(config.serverPubKeyHex),
72
- csvTimelock: {
73
- value: BigInt(config.exitTimelockValue),
74
- type: config.exitTimelockType,
75
- },
76
- });
77
65
  const deps = (0, taskRunner_1.createTaskDependencies)({
78
66
  walletRepository,
79
67
  contractRepository,
80
68
  indexerProvider,
81
69
  arkProvider,
82
- offchainTapscript,
83
70
  });
84
71
  await (0, taskRunner_1.runTasks)(taskQueue, processors, deps);
85
72
  // Acknowledge outbox results (no foreground to consume them)
@@ -103,12 +103,7 @@ class ExpoWallet {
103
103
  contractRepository: wallet.contractRepository,
104
104
  indexerProvider: wallet.indexerProvider,
105
105
  arkProvider: wallet.arkProvider,
106
- extendVtxo: (vtxo, contract) => {
107
- if (contract) {
108
- return (0, utils_1.extendVtxoFromContract)(vtxo, contract);
109
- }
110
- return (0, utils_1.extendVirtualCoin)(wallet, vtxo);
111
- },
106
+ extendVtxo: (vtxo, contract) => (0, utils_1.extendVirtualCoinForContract)(vtxo, contract),
112
107
  };
113
108
  const { taskQueue } = config.background;
114
109
  // Persist wallet params so the background handler can rehydrate
@@ -245,6 +245,15 @@ class WalletMessageHandler {
245
245
  payload: { contracts },
246
246
  });
247
247
  }
248
+ case "ANNOTATE_VTXOS": {
249
+ const manager = await this.readonlyWallet.getContractManager();
250
+ const annotated = await manager.annotateVtxos(message.payload.vtxos);
251
+ return this.tagged({
252
+ id,
253
+ type: "ANNOTATED_VTXOS",
254
+ payload: { vtxos: annotated },
255
+ });
256
+ }
248
257
  case "UPDATE_CONTRACT": {
249
258
  const manager = await this.readonlyWallet.getContractManager();
250
259
  const contract = await manager.updateContract(message.payload.script, message.payload.updates);
@@ -560,13 +569,13 @@ class WalletMessageHandler {
560
569
  this.incomingFundsSubscription =
561
570
  await this.readonlyWallet.notifyIncomingFunds(async (funds) => {
562
571
  if (funds.type === "vtxo") {
563
- const newVtxos = funds.newVtxos.length > 0
564
- ? funds.newVtxos.map((vtxo) => (0, utils_1.extendVirtualCoin)(this.readonlyWallet, vtxo))
565
- : [];
566
- const spentVtxos = funds.spentVtxos.length > 0
567
- ? funds.spentVtxos.map((vtxo) => (0, utils_1.extendVirtualCoin)(this.readonlyWallet, vtxo))
568
- : [];
569
- if ([...newVtxos, ...spentVtxos].length === 0)
572
+ // `funds.newVtxos` / `funds.spentVtxos` are already
573
+ // ExtendedVirtualCoin annotation happened inside the
574
+ // underlying Wallet's subscription handler before this
575
+ // callback fired. Re-annotating here would only duplicate
576
+ // work and re-expose us to `annotateVtxos` throws.
577
+ const { newVtxos, spentVtxos } = funds;
578
+ if (newVtxos.length + spentVtxos.length === 0)
570
579
  return;
571
580
  // save virtual outputs using unified repository
572
581
  await this.walletRepository?.saveVtxos(address, [
@@ -28,6 +28,7 @@ exports.DEFAULT_MESSAGE_TIMEOUTS = {
28
28
  GET_TRANSACTION_HISTORY: 20000,
29
29
  GET_CONTRACTS: 20000,
30
30
  GET_CONTRACTS_WITH_VTXOS: 20000,
31
+ ANNOTATE_VTXOS: 20000,
31
32
  GET_SPENDABLE_PATHS: 20000,
32
33
  GET_ALL_SPENDING_PATHS: 20000,
33
34
  GET_ASSET_DETAILS: 20000,
@@ -69,6 +70,7 @@ const DEDUPABLE_REQUEST_TYPES = new Set([
69
70
  "GET_VTXOS",
70
71
  "GET_CONTRACTS",
71
72
  "GET_CONTRACTS_WITH_VTXOS",
73
+ "ANNOTATE_VTXOS",
72
74
  "GET_SPENDABLE_PATHS",
73
75
  "GET_ALL_SPENDING_PATHS",
74
76
  "GET_ASSET_DETAILS",
@@ -656,6 +658,23 @@ class ServiceWorkerReadonlyWallet {
656
658
  throw new Error("Failed to get contracts with vtxos");
657
659
  }
658
660
  },
661
+ async annotateVtxos(vtxos) {
662
+ if (vtxos.length === 0)
663
+ return [];
664
+ const message = {
665
+ type: "ANNOTATE_VTXOS",
666
+ id: (0, utils_2.getRandomId)(),
667
+ tag: messageTag,
668
+ payload: { vtxos },
669
+ };
670
+ try {
671
+ const response = await sendContractMessage(message);
672
+ return response.payload.vtxos;
673
+ }
674
+ catch (e) {
675
+ throw new Error("Failed to annotate vtxos");
676
+ }
677
+ },
659
678
  async updateContract(script, updates) {
660
679
  const message = {
661
680
  type: "UPDATE_CONTRACT",