@arkade-os/sdk 0.4.17 → 0.4.18

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 (68) 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/repositories/indexedDB/manager.js +6 -3
  6. package/dist/cjs/repositories/indexedDB/schema.js +47 -2
  7. package/dist/cjs/repositories/indexedDB/walletRepository.js +21 -2
  8. package/dist/cjs/repositories/realm/contractRepository.js +0 -4
  9. package/dist/cjs/repositories/realm/index.js +3 -1
  10. package/dist/cjs/repositories/realm/schemas.js +50 -1
  11. package/dist/cjs/repositories/realm/walletRepository.js +8 -4
  12. package/dist/cjs/repositories/scriptFromAddress.js +16 -0
  13. package/dist/cjs/repositories/sqlite/contractRepository.js +2 -6
  14. package/dist/cjs/repositories/sqlite/walletRepository.js +121 -33
  15. package/dist/cjs/utils/syncCursors.js +48 -56
  16. package/dist/cjs/wallet/expo/background.js +0 -13
  17. package/dist/cjs/wallet/expo/wallet.js +1 -6
  18. package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +16 -7
  19. package/dist/cjs/wallet/serviceWorker/wallet.js +19 -0
  20. package/dist/cjs/wallet/utils.js +41 -10
  21. package/dist/cjs/wallet/vtxo-manager.js +153 -39
  22. package/dist/cjs/wallet/wallet.js +72 -195
  23. package/dist/cjs/worker/expo/processors/contractPollProcessor.js +9 -13
  24. package/dist/cjs/worker/expo/taskRunner.js +2 -11
  25. package/dist/esm/contracts/arkcontract.js +0 -2
  26. package/dist/esm/contracts/contractManager.js +113 -217
  27. package/dist/esm/contracts/contractWatcher.js +86 -115
  28. package/dist/esm/repositories/indexedDB/manager.js +6 -3
  29. package/dist/esm/repositories/indexedDB/schema.js +46 -2
  30. package/dist/esm/repositories/indexedDB/walletRepository.js +21 -2
  31. package/dist/esm/repositories/realm/contractRepository.js +0 -4
  32. package/dist/esm/repositories/realm/index.js +1 -1
  33. package/dist/esm/repositories/realm/schemas.js +48 -0
  34. package/dist/esm/repositories/realm/walletRepository.js +8 -4
  35. package/dist/esm/repositories/scriptFromAddress.js +13 -0
  36. package/dist/esm/repositories/sqlite/contractRepository.js +2 -6
  37. package/dist/esm/repositories/sqlite/walletRepository.js +121 -33
  38. package/dist/esm/utils/syncCursors.js +47 -53
  39. package/dist/esm/wallet/expo/background.js +0 -13
  40. package/dist/esm/wallet/expo/wallet.js +2 -7
  41. package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +17 -8
  42. package/dist/esm/wallet/serviceWorker/wallet.js +19 -0
  43. package/dist/esm/wallet/utils.js +41 -9
  44. package/dist/esm/wallet/vtxo-manager.js +153 -39
  45. package/dist/esm/wallet/wallet.js +75 -198
  46. package/dist/esm/worker/expo/processors/contractPollProcessor.js +9 -13
  47. package/dist/esm/worker/expo/taskRunner.js +3 -12
  48. package/dist/types/contracts/arkcontract.d.ts +0 -2
  49. package/dist/types/contracts/contractManager.d.ts +38 -9
  50. package/dist/types/contracts/contractWatcher.d.ts +22 -21
  51. package/dist/types/contracts/types.d.ts +0 -7
  52. package/dist/types/repositories/indexedDB/manager.d.ts +5 -2
  53. package/dist/types/repositories/indexedDB/schema.d.ts +3 -2
  54. package/dist/types/repositories/realm/index.d.ts +1 -1
  55. package/dist/types/repositories/realm/schemas.d.ts +41 -0
  56. package/dist/types/repositories/scriptFromAddress.d.ts +9 -0
  57. package/dist/types/repositories/serialization.d.ts +1 -1
  58. package/dist/types/repositories/sqlite/walletRepository.d.ts +22 -0
  59. package/dist/types/repositories/walletRepository.d.ts +10 -2
  60. package/dist/types/utils/syncCursors.d.ts +25 -23
  61. package/dist/types/wallet/index.d.ts +1 -1
  62. package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +15 -3
  63. package/dist/types/wallet/utils.d.ts +20 -4
  64. package/dist/types/wallet/vtxo-manager.d.ts +16 -6
  65. package/dist/types/wallet/wallet.d.ts +5 -17
  66. package/dist/types/worker/expo/processors/contractPollProcessor.d.ts +9 -4
  67. package/dist/types/worker/expo/taskRunner.d.ts +6 -3
  68. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { serializeVtxo, serializeUtxo, deserializeVtxo, deserializeUtxo, } from '../serialization.js';
2
+ import { scriptFromArkAddress } from '../scriptFromAddress.js';
2
3
  /**
3
4
  * SQLite-based implementation of WalletRepository.
4
5
  *
@@ -29,30 +30,7 @@ export class SQLiteWalletRepository {
29
30
  return this.initPromise;
30
31
  }
31
32
  async init() {
32
- await this.db.run(`
33
- CREATE TABLE IF NOT EXISTS ${this.tables.vtxos} (
34
- txid TEXT NOT NULL,
35
- vout INTEGER NOT NULL,
36
- value INTEGER NOT NULL,
37
- address TEXT NOT NULL,
38
- tap_tree TEXT NOT NULL,
39
- forfeit_cb TEXT NOT NULL,
40
- forfeit_s TEXT NOT NULL,
41
- intent_cb TEXT NOT NULL,
42
- intent_s TEXT NOT NULL,
43
- status_json TEXT NOT NULL,
44
- virtual_status_json TEXT NOT NULL,
45
- created_at TEXT NOT NULL,
46
- is_unrolled INTEGER NOT NULL DEFAULT 0,
47
- is_spent INTEGER,
48
- spent_by TEXT,
49
- settled_by TEXT,
50
- ark_tx_id TEXT,
51
- extra_witness_json TEXT,
52
- assets_json TEXT,
53
- PRIMARY KEY (txid, vout)
54
- )
55
- `);
33
+ await this.migrateVtxosTable();
56
34
  await this.db.run(`
57
35
  CREATE TABLE IF NOT EXISTS ${this.tables.utxos} (
58
36
  txid TEXT NOT NULL,
@@ -86,14 +64,121 @@ export class SQLiteWalletRepository {
86
64
  await this.db.run(`
87
65
  CREATE TABLE IF NOT EXISTS ${this.tables.walletState} (
88
66
  key TEXT PRIMARY KEY,
89
- last_sync_time INTEGER,
90
- settings_json TEXT
67
+ settings_json TEXT,
68
+ last_sync_time INTEGER
91
69
  )
92
70
  `);
93
71
  await this.db.run(`CREATE INDEX IF NOT EXISTS idx_${this.prefix}vtxos_address ON ${this.tables.vtxos} (address)`);
72
+ await this.db.run(`CREATE INDEX IF NOT EXISTS idx_${this.prefix}vtxos_script ON ${this.tables.vtxos} (script)`);
94
73
  await this.db.run(`CREATE INDEX IF NOT EXISTS idx_${this.prefix}utxos_address ON ${this.tables.utxos} (address)`);
95
74
  await this.db.run(`CREATE INDEX IF NOT EXISTS idx_${this.prefix}transactions_address ON ${this.tables.transactions} (address)`);
96
75
  }
76
+ /**
77
+ * Bring the `vtxos` table to the current schema (v1 = `script` NOT NULL).
78
+ *
79
+ * Three cases:
80
+ * - Fresh install: create the v1 schema directly.
81
+ * - Legacy install without a `script` column: add it, backfill from
82
+ * `address`, then rebuild the table with NOT NULL (SQLite cannot add
83
+ * the NOT NULL constraint in place).
84
+ * - Legacy install with a nullable `script` column: backfill the NULLs
85
+ * and rebuild.
86
+ *
87
+ * The backfill derives `script` from the Ark address, matching what the
88
+ * indexer would have returned — new rows from the indexer always carry a
89
+ * populated `script`, so the migration is idempotent.
90
+ *
91
+ * The rebuild path is wrapped in a transaction: without it, a crash
92
+ * between the `DROP TABLE vtxos` and the `RENAME tmp → vtxos` commits
93
+ * would leave the next startup seeing no `vtxos` table and create a
94
+ * fresh empty one, silently orphaning every row in the temp table.
95
+ */
96
+ async migrateVtxosTable() {
97
+ const tableExists = await this.db.get(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, [this.tables.vtxos]);
98
+ if (!tableExists) {
99
+ await this.db.run(this.vtxosCreateSql(this.tables.vtxos));
100
+ return;
101
+ }
102
+ const cols = await this.db.all(`PRAGMA table_info(${this.tables.vtxos})`);
103
+ const scriptCol = cols.find((c) => c.name === "script");
104
+ if (scriptCol && scriptCol.notnull === 1) {
105
+ // Already on v1 schema.
106
+ return;
107
+ }
108
+ await this.db.run("BEGIN IMMEDIATE");
109
+ try {
110
+ if (!scriptCol) {
111
+ await this.db.run(`ALTER TABLE ${this.tables.vtxos} ADD COLUMN script TEXT`);
112
+ }
113
+ // Backfill any NULL scripts from the owning address. Derivation
114
+ // is deterministic, so re-running after a rolled-back attempt
115
+ // produces the same values.
116
+ const nullRows = await this.db.all(`SELECT txid, vout, address FROM ${this.tables.vtxos} WHERE script IS NULL`);
117
+ for (const row of nullRows) {
118
+ await this.db.run(`UPDATE ${this.tables.vtxos} SET script = ? WHERE txid = ? AND vout = ?`, [scriptFromArkAddress(row.address), row.txid, row.vout]);
119
+ }
120
+ // SQLite can't turn a nullable column into NOT NULL in place —
121
+ // do the canonical 12-step table rebuild. Drop any stale temp
122
+ // table from a prior aborted attempt first.
123
+ const tempName = `${this.tables.vtxos}__migrate_tmp`;
124
+ await this.db.run(`DROP TABLE IF EXISTS ${tempName}`);
125
+ await this.db.run(this.vtxosCreateSql(tempName));
126
+ await this.db.run(`
127
+ INSERT INTO ${tempName}
128
+ (txid, vout, value, address, tap_tree,
129
+ forfeit_cb, forfeit_s, intent_cb, intent_s,
130
+ status_json, virtual_status_json, created_at,
131
+ is_unrolled, is_spent, spent_by, settled_by, ark_tx_id,
132
+ extra_witness_json, assets_json, script)
133
+ SELECT txid, vout, value, address, tap_tree,
134
+ forfeit_cb, forfeit_s, intent_cb, intent_s,
135
+ status_json, virtual_status_json, created_at,
136
+ is_unrolled, is_spent, spent_by, settled_by, ark_tx_id,
137
+ extra_witness_json, assets_json, script
138
+ FROM ${this.tables.vtxos}
139
+ `);
140
+ await this.db.run(`DROP TABLE ${this.tables.vtxos}`);
141
+ await this.db.run(`ALTER TABLE ${tempName} RENAME TO ${this.tables.vtxos}`);
142
+ await this.db.run("COMMIT");
143
+ }
144
+ catch (e) {
145
+ // A failed COMMIT auto-rolls-back in SQLite, which makes a
146
+ // follow-up ROLLBACK throw "no transaction is active". Swallow
147
+ // that secondary error so the original cause surfaces.
148
+ try {
149
+ await this.db.run("ROLLBACK");
150
+ }
151
+ catch {
152
+ // already rolled back
153
+ }
154
+ throw e;
155
+ }
156
+ }
157
+ vtxosCreateSql(tableName) {
158
+ return `CREATE TABLE ${tableName} (
159
+ txid TEXT NOT NULL,
160
+ vout INTEGER NOT NULL,
161
+ value INTEGER NOT NULL,
162
+ address TEXT NOT NULL,
163
+ tap_tree TEXT NOT NULL,
164
+ forfeit_cb TEXT NOT NULL,
165
+ forfeit_s TEXT NOT NULL,
166
+ intent_cb TEXT NOT NULL,
167
+ intent_s TEXT NOT NULL,
168
+ status_json TEXT NOT NULL,
169
+ virtual_status_json TEXT NOT NULL,
170
+ created_at TEXT NOT NULL,
171
+ is_unrolled INTEGER NOT NULL DEFAULT 0,
172
+ is_spent INTEGER,
173
+ spent_by TEXT,
174
+ settled_by TEXT,
175
+ ark_tx_id TEXT,
176
+ extra_witness_json TEXT,
177
+ assets_json TEXT,
178
+ script TEXT NOT NULL,
179
+ PRIMARY KEY (txid, vout)
180
+ )`;
181
+ }
97
182
  async [Symbol.asyncDispose]() {
98
183
  // no-op — consumer owns the SQLExecutor lifecycle
99
184
  }
@@ -120,12 +205,12 @@ export class SQLiteWalletRepository {
120
205
  tap_tree, forfeit_cb, forfeit_s, intent_cb, intent_s,
121
206
  status_json, virtual_status_json, created_at,
122
207
  is_unrolled, is_spent, spent_by, settled_by, ark_tx_id,
123
- extra_witness_json, assets_json)
208
+ extra_witness_json, assets_json, script)
124
209
  VALUES (?, ?, ?, ?,
125
210
  ?, ?, ?, ?, ?,
126
211
  ?, ?, ?,
127
212
  ?, ?, ?, ?, ?,
128
- ?, ?)`, [
213
+ ?, ?, ?)`, [
129
214
  s.txid,
130
215
  s.vout,
131
216
  s.value,
@@ -149,6 +234,7 @@ export class SQLiteWalletRepository {
149
234
  s.arkTxId ?? null,
150
235
  s.extraWitness ? JSON.stringify(s.extraWitness) : null,
151
236
  s.assets ? JSON.stringify(s.assets) : null,
237
+ s.script ?? null,
152
238
  ]);
153
239
  }
154
240
  }
@@ -228,22 +314,20 @@ export class SQLiteWalletRepository {
228
314
  if (!row)
229
315
  return null;
230
316
  const state = {};
231
- if (row.last_sync_time !== null && row.last_sync_time !== undefined) {
232
- state.lastSyncTime = row.last_sync_time;
233
- }
234
317
  if (row.settings_json) {
235
318
  state.settings = JSON.parse(row.settings_json);
236
319
  }
320
+ state.lastSyncTime = row.last_sync_time ?? undefined;
237
321
  return state;
238
322
  }
239
323
  async saveWalletState(state) {
240
324
  await this.ensureInit();
241
325
  await this.db.run(`INSERT OR REPLACE INTO ${this.tables.walletState}
242
- (key, last_sync_time, settings_json)
326
+ (key, settings_json, last_sync_time)
243
327
  VALUES (?, ?, ?)`, [
244
328
  "state",
245
- state.lastSyncTime ?? null,
246
329
  state.settings ? JSON.stringify(state.settings) : null,
330
+ state.lastSyncTime ?? null,
247
331
  ]);
248
332
  }
249
333
  }
@@ -281,6 +365,10 @@ function vtxoRowToDomain(row) {
281
365
  ? JSON.parse(row.extra_witness_json)
282
366
  : undefined,
283
367
  assets: row.assets_json ? JSON.parse(row.assets_json) : undefined,
368
+ // Post-migration every row has `script`, but the backfill is
369
+ // idempotent: derive from `address` if the legacy column is still
370
+ // null (e.g. the migration hasn't run yet on this handle).
371
+ script: row.script ?? scriptFromArkAddress(row.address),
284
372
  };
285
373
  return deserializeVtxo(serialized);
286
374
  }
@@ -1,11 +1,10 @@
1
1
  /** Lag behind real-time to avoid racing with indexer writes. */
2
2
  export const SAFETY_LAG_MS = 30000;
3
3
  /** Overlap window so boundary virtual outputs are never missed. */
4
- export const OVERLAP_MS = 60000;
4
+ export const OVERLAP_MS = 24 * 60 * 60 * 1000;
5
5
  /**
6
6
  * Per-repository mutex that serializes wallet-state mutations so that
7
- * concurrent read-modify-write cycles (e.g. advanceSyncCursors racing
8
- * with clearSyncCursors or setPendingTxFlag) never silently overwrite
7
+ * concurrent read-modify-write cycles never silently overwrite
9
8
  * each other's changes.
10
9
  */
11
10
  const walletStateLocks = new WeakMap();
@@ -25,88 +24,83 @@ export async function updateWalletState(repo, updater) {
25
24
  return op;
26
25
  }
27
26
  /**
28
- * Read the high-water mark for a single script.
29
- * Returns `undefined` when the script has never been synced (bootstrap case).
27
+ * Settings key that gates interpretation of the `lastSyncTime` field.
28
+ *
29
+ * The `lastSyncTime` column existed pre-PR with a different semantic
30
+ * (wall-clock at sync completion, written by the buggy sync loop this
31
+ * PR fixes). On upgrade we cannot trust any pre-existing value, so the
32
+ * cursor is only honoured after the first successful post-upgrade
33
+ * advance writes this marker into the `settings` JSON blob. Reusing
34
+ * `settings` avoids any schema migration.
30
35
  */
31
- export async function getSyncCursor(repo, script) {
32
- const state = await repo.getWalletState();
33
- return state?.settings?.vtxoSyncCursors?.[script];
36
+ const CURSOR_MIGRATED_KEY = "vtxoCursorMigrated";
37
+ function hasMigrationMarker(state) {
38
+ return state?.settings?.[CURSOR_MIGRATED_KEY] === true;
34
39
  }
35
40
  /**
36
- * Read cursors for every previously-synced script.
41
+ * Read the global high-water mark for VTXO indexer syncs.
42
+ *
43
+ * Returns `0` when:
44
+ * - the wallet has never been synced (bootstrap case), or
45
+ * - the stored `lastSyncTime` was written by pre-PR code and is not
46
+ * safe to reuse under the new semantics (see {@link CURSOR_MIGRATED_KEY}).
37
47
  */
38
- export async function getAllSyncCursors(repo) {
48
+ export async function getSyncCursor(repo) {
39
49
  const state = await repo.getWalletState();
40
- return state?.settings?.vtxoSyncCursors ?? {};
50
+ if (!hasMigrationMarker(state))
51
+ return 0;
52
+ return state?.lastSyncTime ?? 0;
41
53
  }
42
54
  /**
43
- * Advance the cursor for one script after a successful delta sync.
44
- * `cursor` should be the `before` cutoff used in the request.
45
- */
46
- export async function advanceSyncCursor(repo, script, cursor) {
47
- await updateWalletState(repo, (state) => {
48
- const existing = state.settings?.vtxoSyncCursors ?? {};
49
- return {
50
- ...state,
51
- settings: {
52
- ...state.settings,
53
- vtxoSyncCursors: { ...existing, [script]: cursor },
54
- },
55
- };
56
- });
57
- }
58
- /**
59
- * Advance cursors for multiple scripts in a single write.
55
+ * Advance the global cursor after a successful full-scope delta sync.
56
+ *
57
+ * Clamped with `Math.max` against the current value so concurrent syncs
58
+ * that finish out of order can't rewind the cursor: `lastUpdatedAt` is
59
+ * captured before each sync enters the `updateWalletState` mutex, and
60
+ * the later-started sync would otherwise overwrite the earlier-captured
61
+ * one with a smaller value. The legacy value is discarded on the first
62
+ * advance if the migration marker is absent so pre-PR data doesn't
63
+ * survive the upgrade.
60
64
  */
61
- export async function advanceSyncCursors(repo, updates) {
65
+ export async function advanceSyncCursor(repo, lastUpdatedAt) {
62
66
  await updateWalletState(repo, (state) => {
63
- const existing = state.settings?.vtxoSyncCursors ?? {};
67
+ const current = hasMigrationMarker(state)
68
+ ? (state.lastSyncTime ?? 0)
69
+ : 0;
64
70
  return {
65
71
  ...state,
72
+ lastSyncTime: Math.max(current, lastUpdatedAt),
66
73
  settings: {
67
- ...state.settings,
68
- vtxoSyncCursors: { ...existing, ...updates },
74
+ ...(state.settings ?? {}),
75
+ [CURSOR_MIGRATED_KEY]: true,
69
76
  },
70
77
  };
71
78
  });
72
79
  }
73
80
  /**
74
- * Remove sync cursors, forcing a full re-bootstrap on next sync.
75
- * When `scripts` is provided, only those cursors are cleared.
81
+ * Remove the sync cursor, forcing a full re-bootstrap on next sync.
82
+ *
83
+ * Also clears the migration marker so any stored `lastSyncTime` is
84
+ * treated as untrusted on the next read.
76
85
  */
77
- export async function clearSyncCursors(repo, scripts) {
86
+ export async function clearSyncCursor(repo) {
78
87
  await updateWalletState(repo, (state) => {
79
- if (!scripts) {
80
- const { vtxoSyncCursors: _, ...restSettings } = state.settings ?? {};
81
- return {
82
- ...state,
83
- settings: restSettings,
84
- };
85
- }
86
- const existing = state.settings?.vtxoSyncCursors ?? {};
87
- const filtered = { ...existing };
88
- for (const s of scripts)
89
- delete filtered[s];
88
+ const { [CURSOR_MIGRATED_KEY]: _, ...restSettings } = state.settings ?? {};
90
89
  return {
91
90
  ...state,
92
- settings: {
93
- ...state.settings,
94
- vtxoSyncCursors: filtered,
95
- },
91
+ lastSyncTime: undefined,
92
+ settings: restSettings,
96
93
  };
97
94
  });
98
95
  }
99
96
  /**
100
97
  * Compute the `after` lower-bound for a delta sync query.
101
- * Returns `undefined` when the script has no cursor (bootstrap needed).
102
98
  *
103
99
  * No upper bound (`before`) is applied to the query so that freshly
104
100
  * created virtual outputs are never excluded. The safety lag is applied only
105
101
  * when advancing the cursor (see @see cursorCutoff).
106
102
  */
107
103
  export function computeSyncWindow(cursor) {
108
- if (cursor === undefined)
109
- return undefined;
110
104
  const after = Math.max(0, cursor - OVERLAP_MS);
111
105
  return { after };
112
106
  }
@@ -1,7 +1,5 @@
1
- import { hex } from "@scure/base";
2
1
  import { runTasks, createTaskDependencies } from '../../worker/expo/taskRunner.js';
3
2
  import { contractPollProcessor, CONTRACT_POLL_TASK_TYPE, } from '../../worker/expo/processors/index.js';
4
- import { DefaultVtxo } from '../../script/default.js';
5
3
  import { ExpoArkProvider } from '../../providers/expoArk.js';
6
4
  import { ExpoIndexerProvider } from '../../providers/expoIndexer.js';
7
5
  import { getRandomId } from '../utils.js';
@@ -59,22 +57,11 @@ export function defineExpoBackgroundTask(taskName, options) {
59
57
  // Reconstruct providers
60
58
  const indexerProvider = new ExpoIndexerProvider(config.arkServerUrl);
61
59
  const arkProvider = new ExpoArkProvider(config.arkServerUrl);
62
- // Reconstruct default offchainTapscript as fallback
63
- // for virtual outputs not associated with a contract.
64
- const offchainTapscript = new DefaultVtxo.Script({
65
- pubKey: hex.decode(config.pubkeyHex),
66
- serverPubKey: hex.decode(config.serverPubKeyHex),
67
- csvTimelock: {
68
- value: BigInt(config.exitTimelockValue),
69
- type: config.exitTimelockType,
70
- },
71
- });
72
60
  const deps = createTaskDependencies({
73
61
  walletRepository,
74
62
  contractRepository,
75
63
  indexerProvider,
76
64
  arkProvider,
77
- offchainTapscript,
78
65
  });
79
66
  await runTasks(taskQueue, processors, deps);
80
67
  // Acknowledge outbox results (no foreground to consume them)
@@ -3,7 +3,7 @@ import { Wallet } from '../wallet.js';
3
3
  import { RestArkProvider } from '../../providers/ark.js';
4
4
  import { runTasks } from '../../worker/expo/taskRunner.js';
5
5
  import { contractPollProcessor, CONTRACT_POLL_TASK_TYPE, } from '../../worker/expo/processors/index.js';
6
- import { extendVirtualCoin, extendVtxoFromContract, getRandomId, } from '../utils.js';
6
+ import { extendVirtualCoinForContract, getRandomId } from '../utils.js';
7
7
  import { DefaultVtxo } from '../../script/default.js';
8
8
  /**
9
9
  * Expo/React Native wallet with built-in background task processing.
@@ -67,12 +67,7 @@ export class ExpoWallet {
67
67
  contractRepository: wallet.contractRepository,
68
68
  indexerProvider: wallet.indexerProvider,
69
69
  arkProvider: wallet.arkProvider,
70
- extendVtxo: (vtxo, contract) => {
71
- if (contract) {
72
- return extendVtxoFromContract(vtxo, contract);
73
- }
74
- return extendVirtualCoin(wallet, vtxo);
75
- },
70
+ extendVtxo: (vtxo, contract) => extendVirtualCoinForContract(vtxo, contract),
76
71
  };
77
72
  const { taskQueue } = config.background;
78
73
  // Persist wallet params so the background handler can rehydrate
@@ -1,6 +1,6 @@
1
1
  import { RestIndexerProvider } from '../../providers/indexer.js';
2
2
  import { isExpired, isRecoverable, isSpendable, isSubdust, } from '../index.js';
3
- import { extendCoin, extendVirtualCoin } from '../utils.js';
3
+ import { extendCoin } from '../utils.js';
4
4
  import { buildTransactionHistory } from '../../utils/transactionHistory.js';
5
5
  export class WalletNotInitializedError extends Error {
6
6
  constructor() {
@@ -239,6 +239,15 @@ export class WalletMessageHandler {
239
239
  payload: { contracts },
240
240
  });
241
241
  }
242
+ case "ANNOTATE_VTXOS": {
243
+ const manager = await this.readonlyWallet.getContractManager();
244
+ const annotated = await manager.annotateVtxos(message.payload.vtxos);
245
+ return this.tagged({
246
+ id,
247
+ type: "ANNOTATED_VTXOS",
248
+ payload: { vtxos: annotated },
249
+ });
250
+ }
242
251
  case "UPDATE_CONTRACT": {
243
252
  const manager = await this.readonlyWallet.getContractManager();
244
253
  const contract = await manager.updateContract(message.payload.script, message.payload.updates);
@@ -554,13 +563,13 @@ export class WalletMessageHandler {
554
563
  this.incomingFundsSubscription =
555
564
  await this.readonlyWallet.notifyIncomingFunds(async (funds) => {
556
565
  if (funds.type === "vtxo") {
557
- const newVtxos = funds.newVtxos.length > 0
558
- ? funds.newVtxos.map((vtxo) => extendVirtualCoin(this.readonlyWallet, vtxo))
559
- : [];
560
- const spentVtxos = funds.spentVtxos.length > 0
561
- ? funds.spentVtxos.map((vtxo) => extendVirtualCoin(this.readonlyWallet, vtxo))
562
- : [];
563
- if ([...newVtxos, ...spentVtxos].length === 0)
566
+ // `funds.newVtxos` / `funds.spentVtxos` are already
567
+ // ExtendedVirtualCoin annotation happened inside the
568
+ // underlying Wallet's subscription handler before this
569
+ // callback fired. Re-annotating here would only duplicate
570
+ // work and re-expose us to `annotateVtxos` throws.
571
+ const { newVtxos, spentVtxos } = funds;
572
+ if (newVtxos.length + spentVtxos.length === 0)
564
573
  return;
565
574
  // save virtual outputs using unified repository
566
575
  await this.walletRepository?.saveVtxos(address, [
@@ -25,6 +25,7 @@ export const DEFAULT_MESSAGE_TIMEOUTS = {
25
25
  GET_TRANSACTION_HISTORY: 20000,
26
26
  GET_CONTRACTS: 20000,
27
27
  GET_CONTRACTS_WITH_VTXOS: 20000,
28
+ ANNOTATE_VTXOS: 20000,
28
29
  GET_SPENDABLE_PATHS: 20000,
29
30
  GET_ALL_SPENDING_PATHS: 20000,
30
31
  GET_ASSET_DETAILS: 20000,
@@ -66,6 +67,7 @@ const DEDUPABLE_REQUEST_TYPES = new Set([
66
67
  "GET_VTXOS",
67
68
  "GET_CONTRACTS",
68
69
  "GET_CONTRACTS_WITH_VTXOS",
70
+ "ANNOTATE_VTXOS",
69
71
  "GET_SPENDABLE_PATHS",
70
72
  "GET_ALL_SPENDING_PATHS",
71
73
  "GET_ASSET_DETAILS",
@@ -653,6 +655,23 @@ export class ServiceWorkerReadonlyWallet {
653
655
  throw new Error("Failed to get contracts with vtxos");
654
656
  }
655
657
  },
658
+ async annotateVtxos(vtxos) {
659
+ if (vtxos.length === 0)
660
+ return [];
661
+ const message = {
662
+ type: "ANNOTATE_VTXOS",
663
+ id: getRandomId(),
664
+ tag: messageTag,
665
+ payload: { vtxos },
666
+ };
667
+ try {
668
+ const response = await sendContractMessage(message);
669
+ return response.payload.vtxos;
670
+ }
671
+ catch (e) {
672
+ throw new Error("Failed to annotate vtxos");
673
+ }
674
+ },
656
675
  async updateContract(script, updates) {
657
676
  const message = {
658
677
  type: "UPDATE_CONTRACT",
@@ -2,14 +2,6 @@ import { ArkAddress, } from '../index.js';
2
2
  import { contractHandlers } from '../contracts/handlers/index.js';
3
3
  import { hex } from "@scure/base";
4
4
  export const DUST_AMOUNT = 546; // sats
5
- export function extendVirtualCoin(wallet, vtxo) {
6
- return {
7
- ...vtxo,
8
- forfeitTapLeafScript: wallet.offchainTapscript.forfeit(),
9
- intentTapLeafScript: wallet.offchainTapscript.forfeit(),
10
- tapTree: wallet.offchainTapscript.encode(),
11
- };
12
- }
13
5
  export function extendCoin(wallet, utxo) {
14
6
  return {
15
7
  ...utxo,
@@ -18,7 +10,7 @@ export function extendCoin(wallet, utxo) {
18
10
  tapTree: wallet.boardingTapscript.encode(),
19
11
  };
20
12
  }
21
- export function extendVtxoFromContract(vtxo, contract) {
13
+ function extendVtxoFromContract(vtxo, contract) {
22
14
  const handler = contractHandlers.get(contract.type);
23
15
  if (!handler) {
24
16
  throw new Error(`No handler for contract type '${contract.type}'`);
@@ -31,6 +23,46 @@ export function extendVtxoFromContract(vtxo, contract) {
31
23
  tapTree: script.encode(),
32
24
  };
33
25
  }
26
+ /**
27
+ * Extend a VirtualCoin with the tap scripts of whichever contract locks it.
28
+ *
29
+ * The second argument accepts either form, so each callsite passes what it
30
+ * already has:
31
+ * - a single `Contract` (when the caller already knows the owning contract,
32
+ * e.g. the contract manager iterating its own `scriptToContract` map), or
33
+ * - a `ReadonlyMap<script, Contract>` (when the caller resolves by
34
+ * `vtxo.script`, populated by the indexer).
35
+ *
36
+ * Throws when no contract can be resolved — there is intentionally no
37
+ * default-tapscript fallback. When the wallet owns multiple contracts
38
+ * (default + delegate, several active vHTLCs, etc.) a default-tapscript path
39
+ * silently stamps every VTXO with the same forfeit/intent data, overwriting
40
+ * the correct data for any VTXO locked to a non-default contract. Callers
41
+ * must feed a Contract or a populated script→Contract map; otherwise the
42
+ * caller (typically `ContractManager.annotateVtxos`) should fetch the owning
43
+ * contract first.
44
+ */
45
+ export function extendVirtualCoinForContract(vtxo, contractOrMap) {
46
+ const contract = resolveContract(vtxo, contractOrMap);
47
+ if (!contract) {
48
+ throw new Error("extendVirtualCoinForContract: no contract matched vtxo.script — callers must resolve the owning contract before annotating");
49
+ }
50
+ return extendVtxoFromContract(vtxo, contract);
51
+ }
52
+ function isContractMap(value) {
53
+ // A `Contract` is a plain object with a string `type`. `ReadonlyMap` is
54
+ // an interface so `instanceof Map` is not enough to narrow it — but a
55
+ // contract has no `get` method, so duck-typing on that is unambiguous.
56
+ return typeof value.get === "function";
57
+ }
58
+ function resolveContract(vtxo, contractOrMap) {
59
+ if (!contractOrMap)
60
+ return undefined;
61
+ if (isContractMap(contractOrMap)) {
62
+ return contractOrMap.get(vtxo.script);
63
+ }
64
+ return contractOrMap;
65
+ }
34
66
  export function getRandomId() {
35
67
  const randomValue = crypto.getRandomValues(new Uint8Array(16));
36
68
  return hex.encode(randomValue);