@qntx/wallet-engine 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,19 +1,20 @@
1
- import { A as parseWith, M as INSTALL_ID_HEADER, c as networkById, i as servedNetworksResponse, t as networksPath, u as esploraBasePath, x as rpcPath } from "./contract-D5pguqiZ.mjs";
1
+ import { A as rpcPath, C as EVM_RPC_METHODS, I as parseWith, c as networkById, i as servedNetworksResponse, t as networksPath, u as esploraBasePath, v as INSTALL_ID_HEADER } from "./contract-CEsgLme3.mjs";
2
2
  import { createStore } from "zustand/vanilla";
3
+ import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
4
+ import { ChainError } from "@qntx/wallet-chains";
5
+ import { WalletError, parseBackupHeader } from "@qntx/wallet";
3
6
  import { Address, OutScript, Transaction } from "@scure/btc-signer";
4
7
  import { SparkInputError, bitcoinAddressScript, createSparkClient, decodeBolt11, decodeSparkAddress, encodeSparkAddress, parseSparkInvoice, uuidv7 } from "@qntx/spark";
5
8
  import { BITCOIN_NETWORKS, bitcoinAddressFromXpub, bitcoinNetwork, bitcoinPubkeyFromXpub, decodeBitcoinPsbt } from "@qntx/wallet-chains/bitcoin";
6
- import { checksumEvmAddress, decodeEvm, isEvmAddress } from "@qntx/wallet-chains/evm";
9
+ import { checksumEvmAddress, decodeEvm, isEvmAddress, normalizeEvm } from "@qntx/wallet-chains/evm";
7
10
  import { PROGRAM_IDS, decodeSolana, isSolanaAddress, netAfterTransferFee, parseMintExtensions } from "@qntx/wallet-chains/solana";
8
- import { WalletError, parseBackupHeader } from "@qntx/wallet";
9
- import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
10
11
  import { decodeLnurl, encodeLnurl, lightningAddressUrl, registerLightningAddress, requestInvoice, resolveLnurl, submitWithdraw, unregisterLightningAddress } from "@qntx/spark/lnurl";
11
12
  import { signedMatchesUnsigned, verifyEvent } from "@qntx/nostr";
12
13
  import { makeZapRequest } from "@qntx/nostr/nips/nip57";
14
+ import { chains } from "@qntx/wallet/chains";
13
15
  import { nostr } from "@qntx/wallet/nostr";
14
16
  import { spark } from "@qntx/wallet/spark";
15
17
  import { createPublicClient, decodeFunctionResult, encodeFunctionData, fallback, http, isAddress, isHex, numberToHex } from "viem";
16
- import { chains } from "@qntx/wallet/chains";
17
18
  import { sha256 } from "@noble/hashes/sha2.js";
18
19
  import { base64, hex } from "@scure/base";
19
20
  import { p2wpkh } from "@scure/btc-signer/payment.js";
@@ -22,7 +23,7 @@ import { getSetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction }
22
23
  import { getTransferSolInstruction } from "@solana-program/system";
23
24
  import { TOKEN_PROGRAM_ADDRESS, findAssociatedTokenPda, getCreateAssociatedTokenIdempotentInstruction, getTransferCheckedInstruction } from "@solana-program/token";
24
25
  import { address, appendTransactionMessageInstructions, blockhash, compileTransactionMessage, createNoopSigner, createTransactionMessage, getCompiledTransactionMessageEncoder, setTransactionMessageFeePayer, setTransactionMessageLifetimeUsingBlockhash } from "@solana/kit";
25
- //#region src/controller.ts
26
+ // src/controller.ts
26
27
  /**
27
28
  * Controller base: a zustand vanilla store plus lifecycle. `update` applies a recipe that must
28
29
  * return a NEW state object; returning the same reference is a no-op (no set, no notification).
@@ -42,8 +43,7 @@ var Controller = class {
42
43
  this.store.setState((state) => recipe(state));
43
44
  }
44
45
  };
45
- //#endregion
46
- //#region src/events.ts
46
+ // src/events.ts
47
47
  function createEventBus(logger) {
48
48
  const listeners = /* @__PURE__ */ new Map();
49
49
  return {
@@ -70,8 +70,7 @@ function createEventBus(logger) {
70
70
  }
71
71
  };
72
72
  }
73
- //#endregion
74
- //#region src/errors.ts
73
+ // src/errors.ts
75
74
  /** Typed engine errors: store codes are `closed` and `integrity` (decrypt or bind failure). */
76
75
  var EngineStoreError = class extends Error {
77
76
  code;
@@ -81,14 +80,12 @@ var EngineStoreError = class extends Error {
81
80
  this.code = code;
82
81
  }
83
82
  };
84
- //#endregion
85
- //#region src/store/guard.ts
83
+ // src/store/guard.ts
86
84
  /** NUL is the composite-key/AAD separator — a name or key containing it collides. */
87
85
  function checkName(kind, value) {
88
86
  if (value.includes("\0")) throw new EngineStoreError("invalid-input", `${kind} must not contain NUL`);
89
87
  }
90
- //#endregion
91
- //#region src/store/memory.ts
88
+ // src/store/memory.ts
92
89
  /** In-memory EngineStore: the Node/test implementation — same contract, no durability. */
93
90
  function createMemoryEngineStore(backing) {
94
91
  const collections = backing?.collections ?? /* @__PURE__ */ new Map();
@@ -133,318 +130,357 @@ function createMemoryEngineStore(backing) {
133
130
  }
134
131
  };
135
132
  }
136
- //#endregion
137
- //#region src/addresses/errors.ts
138
- var AddressBookError = class extends Error {
139
- category;
140
- constructor(category, message) {
133
+ // src/connect/errors.ts
134
+ /** EIP-1193 / EIP-1474 / EIP-3326 error: `code` is what the page's promise rejects with. */
135
+ var ProviderError = class extends Error {
136
+ code;
137
+ constructor(code, message) {
141
138
  super(message);
142
- this.name = "AddressBookError";
143
- this.category = category;
139
+ this.name = "ProviderError";
140
+ this.code = code;
144
141
  }
145
142
  };
146
- //#endregion
147
- //#region src/addresses/normalize.ts
148
- /**
149
- * The canonical form of `address` for its family — EIP-55 for EVM, the re-encoded form for bitcoin
150
- * (accepted on any known bitcoin network, since a contact is not tied to the wallet's network) and
151
- * Spark (identity key only, no invoice fields) — or `undefined` when it is not a valid address.
152
- */
153
- function normalizeAddress(family, address) {
154
- try {
155
- if (family === "evm") return isEvmAddress(address) ? checksumEvmAddress(address) : void 0;
156
- if (family === "solana") return isSolanaAddress(address) ? address : void 0;
157
- if (family === "spark") {
158
- const decoded = decodeSparkAddress(address);
159
- return encodeSparkAddress({
160
- network: decoded.network,
161
- identityPublicKey: decoded.identityPublicKey
162
- });
163
- }
164
- for (const network of Object.values(BITCOIN_NETWORKS)) try {
165
- const codec = Address(network.params);
166
- return codec.encode(codec.decode(address));
167
- } catch {}
168
- return;
169
- } catch {
170
- return;
171
- }
172
- }
173
- //#endregion
174
- //#region src/addresses/record.ts
143
+ const userRejectedRequest = (message = "user rejected the request") => new ProviderError(4001, message);
144
+ const unauthorized = (message = "not authorized") => new ProviderError(4100, message);
145
+ const unsupportedMethod = (method) => new ProviderError(4200, `${method} is not supported`);
146
+ const unrecognizedChain = (message = "unrecognized chain") => new ProviderError(4902, message);
147
+ const invalidParams = (message) => new ProviderError(-32602, message);
148
+ const requestPending = (message) => new ProviderError(-32002, message);
149
+ const internalError = (message) => new ProviderError(-32603, message);
150
+ // src/connect/controller.ts
175
151
  /**
176
- * The address-book record shared by the controller, the sync merge and the metadata document: a
177
- * live entry, or a tombstone that carries a deletion to the other devices.
152
+ * DApp connections (CAIP-25 style sessions): one session per origin, authorization by network,
153
+ * account and method, persisted in the engine store and cleared from memory on lock. A connection
154
+ * is approved from a pending queue, by id, like any other request; locking rejects all of it.
178
155
  */
179
- const isTombstone = (record) => "deleted" in record;
180
- const isFamily = (v) => v === "evm" || v === "solana" || v === "bitcoin" || v === "spark";
181
- const ENTRY_KEYS = /* @__PURE__ */ new Set([
182
- "id",
183
- "name",
184
- "family",
185
- "address",
186
- "note",
187
- "createdAt",
188
- "updatedAt"
189
- ]);
190
- const TOMBSTONE_KEYS = /* @__PURE__ */ new Set([
191
- "id",
192
- "deleted",
193
- "updatedAt"
194
- ]);
195
- const isObject$12 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
196
- const isNonNegativeInt$2 = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
197
- /** Trimmed name, or `undefined` when it is not 1 to {@link NAME_MAX} characters. */
198
- const cleanName = (raw) => {
199
- const name = raw.trim();
200
- return name.length > 0 && name.length <= 32 ? name : void 0;
156
+ const COLLECTION$4 = "connections";
157
+ /** `lastUsedAt` is rewritten at most this often. */
158
+ const TOUCH_INTERVAL_MS = 6e4;
159
+ /** Granted for every network a session covers. */
160
+ const SESSION_METHODS = [
161
+ "eth_sendTransaction",
162
+ "personal_sign",
163
+ "eth_signTypedData_v4"
164
+ ];
165
+ const SESSION_NOTIFICATIONS = ["accountsChanged", "chainChanged"];
166
+ /** `eip155:1` → 1; `undefined` for anything that is not an EVM network. */
167
+ const evmChainId = (network) => {
168
+ const match = /^eip155:(\d+)$/.exec(network);
169
+ const id = match?.[1] === void 0 ? void 0 : Number(match[1]);
170
+ return id !== void 0 && Number.isSafeInteger(id) ? id : void 0;
201
171
  };
202
- /** Trimmed note; an empty one means no note. `undefined` return with `ok: false` when too long. */
203
- const cleanNote = (raw) => {
204
- const note = raw?.trim();
205
- if (note === void 0 || note.length === 0) return {
206
- ok: true,
207
- note: void 0
208
- };
209
- return note.length <= 120 ? {
210
- ok: true,
211
- note
212
- } : { ok: false };
172
+ const caip10 = (network, address) => `${network}:${address}`;
173
+ const addressOf$1 = (account) => account.slice(account.lastIndexOf(":") + 1);
174
+ const isObject$14 = (v) => typeof v === "object" && v !== null;
175
+ const isSession = (value) => {
176
+ if (!isObject$14(value) || !isObject$14(value["origin"]) || !isObject$14(value["scopes"])) return false;
177
+ return typeof value["id"] === "string" && value["origin"]["kind"] === "browser" && typeof value["origin"]["origin"] === "string" && typeof value["currentNetwork"] === "string" && typeof value["createdAt"] === "number" && typeof value["lastUsedAt"] === "number";
213
178
  };
214
- const hasOnly = (value, keys) => Object.keys(value).every((k) => keys.has(k));
215
- /**
216
- * Strict decode with the controller's rules; the address is re-normalized. Anything else is
217
- * `undefined`.
218
- */
219
- function decodeRecord(value) {
220
- if (!isObject$12(value) || typeof value["id"] !== "string" || !isNonNegativeInt$2(value["updatedAt"])) return;
221
- if ("deleted" in value) return value["deleted"] === true && hasOnly(value, TOMBSTONE_KEYS) ? {
222
- id: value["id"],
223
- deleted: true,
224
- updatedAt: value["updatedAt"]
225
- } : void 0;
226
- const { family, name, address, note } = value;
227
- if (!hasOnly(value, ENTRY_KEYS) || !isFamily(family) || typeof name !== "string" || typeof address !== "string" || !isNonNegativeInt$2(value["createdAt"]) || note !== void 0 && typeof note !== "string") return;
228
- const cleanedName = cleanName(name);
229
- const normalized = normalizeAddress(family, address);
230
- const cleanedNote = cleanNote(note);
231
- if (cleanedName === void 0 || normalized === void 0 || !cleanedNote.ok || note !== void 0 && cleanedNote.note === void 0) return;
232
- return {
233
- id: value["id"],
234
- name: cleanedName,
235
- family,
236
- address: normalized,
237
- ...cleanedNote.note === void 0 ? {} : { note: cleanedNote.note },
238
- createdAt: value["createdAt"],
239
- updatedAt: value["updatedAt"]
240
- };
241
- }
242
- const serialized = (record) => JSON.stringify(record);
243
- /** The winner of two versions of one id: larger `updatedAt`, then the larger serialization. */
244
- function pickWinner(a, b) {
245
- if (a.updatedAt !== b.updatedAt) return a.updatedAt > b.updatedAt ? a : b;
246
- return serialized(a) >= serialized(b) ? a : b;
247
- }
248
- const pruneTombstones = (records, now) => records.filter((r) => !isTombstone(r) || now - r.updatedAt <= 7776e6);
249
- /** Newest first, id as the tiebreak, so every device orders (and truncates) the same way. */
250
- const newestFirst = (a, b) => b.updatedAt - a.updatedAt || (a.id < b.id ? -1 : 1);
251
- /** Drops the oldest tombstones until at most `max` records remain, or no tombstone is left. */
252
- function evictTombstones(records, max) {
253
- const excess = records.length - max;
254
- if (excess <= 0) return records;
255
- const oldest = new Set(records.filter(isTombstone).sort((a, b) => a.updatedAt - b.updatedAt || (a.id < b.id ? -1 : 1)).slice(0, excess).map((r) => r.id));
256
- return records.filter((r) => !oldest.has(r.id));
257
- }
258
- /** {@link mergeRecords}, also counting the live entries the cap forced out. */
259
- function mergeWithReport(local, remote, now) {
260
- const byId = /* @__PURE__ */ new Map();
261
- for (const record of [...local, ...remote]) {
262
- const existing = byId.get(record.id);
263
- byId.set(record.id, existing === void 0 ? record : pickWinner(existing, record));
264
- }
265
- const live = /* @__PURE__ */ new Map();
266
- const kept = [];
267
- for (const record of [...pruneTombstones([...byId.values()], now)].sort(newestFirst)) {
268
- if (isTombstone(record)) {
269
- kept.push(record);
270
- continue;
271
- }
272
- const key = `${record.family}:${record.address}`;
273
- if (!live.has(key)) {
274
- live.set(key, record);
275
- kept.push(record);
276
- }
277
- }
278
- const evicted = evictTombstones(kept, 128);
279
- return {
280
- records: [...evicted.slice(0, 128)].sort((a, b) => a.id < b.id ? -1 : 1),
281
- droppedLive: evicted.slice(128).filter((r) => !isTombstone(r)).length
282
- };
283
- }
284
- //#endregion
285
- //#region src/addresses/controller.ts
286
- /**
287
- * Address book: named recipients, validated and normalized per chain family, persisted in the
288
- * engine store and cleared from memory on lock. Deletions are kept as tombstones so they reach the
289
- * other devices; `syncEntries`/`merge` are the whole surface the metadata sync needs. Request risk
290
- * checks treat its addresses as known.
291
- */
292
- const COLLECTION$3 = "contacts";
293
- const entriesOf = (records) => Object.fromEntries(records.flatMap((r) => isTombstone(r) ? [] : [[r.id, r]]));
294
- var AddressBookController = class extends Controller {
179
+ var ConnectionController = class extends Controller {
295
180
  options;
296
181
  engineStore;
297
- /** Every record, tombstones included, by id. */
298
- records = /* @__PURE__ */ new Map();
182
+ waiters = /* @__PURE__ */ new Map();
183
+ group;
299
184
  constructor(options) {
300
185
  super({
301
- name: "address-book",
302
- defaultState: () => ({ entries: {} })
186
+ name: "connections",
187
+ defaultState: () => ({
188
+ sessions: {},
189
+ pending: {}
190
+ })
303
191
  });
304
192
  this.options = options;
305
193
  }
306
194
  async init() {
307
195
  const store = await this.options.openStore();
308
196
  this.engineStore = store;
309
- const collection = store.collection(COLLECTION$3);
197
+ this.group = void 0;
198
+ const collection = store.collection(COLLECTION$4);
310
199
  const keys = await collection.list();
311
200
  const rows = await Promise.all(keys.map(async (key) => collection.get(key)));
312
- const records = /* @__PURE__ */ new Map();
313
- for (const raw of rows) {
314
- if (raw === void 0) continue;
315
- const record = decodeRecord(JSON.parse(new TextDecoder().decode(raw)));
316
- if (record === void 0) this.options.logger.warn("address book record skipped: malformed");
317
- else records.set(record.id, record);
201
+ const now = this.options.now();
202
+ const sessions = {};
203
+ const expired = [];
204
+ for (const [index, raw] of rows.entries()) {
205
+ const key = keys[index];
206
+ if (raw === void 0 || key === void 0) continue;
207
+ const parsed = JSON.parse(new TextDecoder().decode(raw));
208
+ if (!isSession(parsed)) {
209
+ this.options.logger.warn("connection session skipped: malformed");
210
+ expired.push(key);
211
+ } else if (now - parsed.lastUsedAt > 7776e6) expired.push(key);
212
+ else sessions[parsed.id] = parsed;
318
213
  }
319
- this.records = records;
320
- this.update(() => ({ entries: entriesOf([...records.values()]) }));
214
+ await Promise.all(expired.map(async (key) => collection.delete(key)));
215
+ this.update(() => ({
216
+ sessions,
217
+ pending: {}
218
+ }));
321
219
  }
322
220
  async destroy() {
323
221
  this.lock();
324
222
  await Promise.resolve();
325
223
  }
224
+ /** Locking is not consent: every pending connection is rejected. */
326
225
  lock() {
327
226
  this.engineStore = void 0;
328
- this.records = /* @__PURE__ */ new Map();
329
- this.update(() => ({ entries: {} }));
227
+ this.group = void 0;
228
+ for (const waiter of this.waiters.values()) waiter.reject(userRejectedRequest("the wallet was locked"));
229
+ this.waiters.clear();
230
+ this.update(() => ({
231
+ sessions: {},
232
+ pending: {}
233
+ }));
330
234
  }
331
- /** Entries and tombstones, for the metadata document. */
332
- syncEntries() {
333
- return [...this.records.values()].sort((a, b) => a.id < b.id ? -1 : 1);
235
+ /** Throws `4100` unless `origin` is `https://host[:port]` (or localhost in development). */
236
+ checkOrigin(origin) {
237
+ let url;
238
+ try {
239
+ url = new URL(origin);
240
+ } catch {
241
+ throw unauthorized("invalid origin");
242
+ }
243
+ const localhost = url.protocol === "http:" && url.hostname === "localhost";
244
+ if (url.origin !== origin || url.protocol !== "https:" && !(localhost && this.options.allowLocalhost)) throw unauthorized("origin not allowed");
245
+ return {
246
+ kind: "browser",
247
+ origin
248
+ };
249
+ }
250
+ session(origin) {
251
+ return Object.values(this.getState().sessions).find((s) => s.origin.origin === origin);
252
+ }
253
+ pending() {
254
+ return Object.values(this.getState().pending);
255
+ }
256
+ /** The group sessions are bound to: the host's last `setActiveGroup`, else the app's selection. */
257
+ activeGroup() {
258
+ return this.group ?? this.options.selectedGroup();
259
+ }
260
+ /** Whether `network` is an EVM network the wallet has enabled. */
261
+ isEnabled(network) {
262
+ return this.options.enabledNetworks().includes(network);
263
+ }
264
+ /** The origin's authorized addresses on its current network; empty when not connected. */
265
+ accounts(origin) {
266
+ const session = this.session(origin);
267
+ return session?.scopes[session.currentNetwork]?.accounts.map(addressOf$1) ?? [];
268
+ }
269
+ /** Throws `4100` unless the origin's session covers `method` on `network`. */
270
+ authorize(origin, network, method) {
271
+ const session = this.session(origin);
272
+ const scope = session?.scopes[network];
273
+ if (session === void 0 || scope === void 0 || !scope.methods.includes(method)) throw unauthorized();
274
+ return session;
275
+ }
276
+ /** Resolves with the session once the host approves; rejects with `4001` when it refuses. */
277
+ async request(origin, kind, network) {
278
+ if (this.engineStore === void 0) throw userRejectedRequest("the wallet is locked");
279
+ if (this.pending().some((p) => p.origin.origin === origin)) throw requestPending("a connection request is already pending");
280
+ const id = `conn-${[...this.options.random(8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
281
+ const entry = {
282
+ id,
283
+ origin: {
284
+ kind: "browser",
285
+ origin
286
+ },
287
+ kind,
288
+ ...network === void 0 ? {} : { network },
289
+ createdAt: this.options.now()
290
+ };
291
+ return new Promise((resolve, reject) => {
292
+ this.waiters.set(id, {
293
+ resolve,
294
+ reject
295
+ });
296
+ this.update((s) => ({
297
+ ...s,
298
+ pending: {
299
+ ...s.pending,
300
+ [id]: entry
301
+ }
302
+ }));
303
+ });
334
304
  }
335
305
  /**
336
- * Merges records from another device (last writer wins, tombstones win over older entries).
337
- * Returns whether anything local changed and whether the merged set holds something `remote`
338
- * lacks, i.e. whether the remote copy needs a newer publish.
306
+ * The host's approval, by id: the accounts granted (EVM addresses of the active group) and the
307
+ * network.
339
308
  */
340
- async merge(remote) {
309
+ async approveConnection(id, grant) {
341
310
  const store = this.requireStore();
342
- const before = JSON.stringify(this.syncEntries());
343
- const { records: merged, droppedLive } = mergeWithReport(this.syncEntries(), remote, this.options.now());
344
- if (droppedLive > 0) this.options.logger.warn(`address book sync: ${droppedLive} contacts dropped, over the 128-record cap`);
345
- const remoteSorted = JSON.stringify([...remote].sort((a, b) => a.id < b.id ? -1 : 1));
346
- const localNewer = JSON.stringify(merged) !== remoteSorted;
347
- if (JSON.stringify(merged) === before) return {
348
- changed: false,
349
- localNewer
311
+ const entry = this.getState().pending[id];
312
+ const waiter = this.waiters.get(id);
313
+ if (entry === void 0 || waiter === void 0) throw new Error(`unknown connection request ${id}`);
314
+ if (entry.kind === "network" && entry.network !== grant.network) throw new Error(`connection request ${id} is for ${String(entry.network)}`);
315
+ if (evmChainId(grant.network) === void 0 || !this.options.enabledNetworks().includes(grant.network)) throw invalidParams(`${grant.network} is not an enabled EVM network`);
316
+ const group = this.activeGroup();
317
+ if (group === void 0) throw new Error("no account group is selected");
318
+ const own = (await this.options.evmAddress(group, grant.network)).toLowerCase();
319
+ if (grant.accounts.length === 0 || grant.accounts.some((a) => a.toLowerCase() !== own)) throw invalidParams("only the active group's account can be granted");
320
+ if (this.getState().pending[id] === void 0) throw new Error(`connection request ${id} is no longer pending`);
321
+ const now = this.options.now();
322
+ const existing = this.session(entry.origin.origin);
323
+ const scope = {
324
+ accounts: [caip10(grant.network, own)],
325
+ methods: SESSION_METHODS,
326
+ notifications: SESSION_NOTIFICATIONS
350
327
  };
351
- const collection = store.collection(COLLECTION$3);
352
- const next = new Map(merged.map((r) => [r.id, r]));
353
- await Promise.all([...merged.map(async (r) => collection.put(r.id, new TextEncoder().encode(JSON.stringify(r)))), ...[...this.records.keys()].filter((id) => !next.has(id)).map(async (id) => collection.delete(id))]);
354
- this.records = next;
355
- this.update(() => ({ entries: entriesOf(merged) }));
356
- return {
357
- changed: true,
358
- localNewer
328
+ const session = {
329
+ id: existing?.id ?? `session-${[...this.options.random(8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`,
330
+ origin: entry.origin,
331
+ scopes: {
332
+ ...existing?.scopes,
333
+ [grant.network]: scope
334
+ },
335
+ currentNetwork: grant.network,
336
+ createdAt: existing?.createdAt ?? now,
337
+ lastUsedAt: now
359
338
  };
360
- }
361
- async add(params) {
339
+ await this.persist(store, session);
340
+ this.dropPending(id);
341
+ if (existing !== void 0 && existing.currentNetwork !== grant.network) this.options.invalidate(entry.origin.origin);
342
+ waiter.resolve(session);
343
+ return session;
344
+ }
345
+ rejectConnection(id) {
346
+ const waiter = this.waiters.get(id);
347
+ if (this.getState().pending[id] === void 0 || waiter === void 0) throw new Error(`unknown connection request ${id}`);
348
+ this.dropPending(id);
349
+ waiter.reject(userRejectedRequest());
350
+ }
351
+ /** Ends the origin's session; its pending connection and unfinished requests are void. */
352
+ async revoke(origin) {
362
353
  const store = this.requireStore();
363
- const name = cleanName(params.name);
364
- if (name === void 0) throw new AddressBookError("invalid-input", `name must be 1 to 32 characters`);
365
- const note = this.checkedNote(params.note);
366
- const address = normalizeAddress(params.family, params.address.trim());
367
- if (address === void 0) throw new AddressBookError("invalid-input", `not a valid ${params.family} address`);
368
- if (Object.values(this.getState().entries).some((e) => e.family === params.family && e.address === address)) throw new AddressBookError("duplicate", "this address is already in the address book");
369
- if (this.records.size >= 128) {
370
- const kept = evictTombstones([...this.records.values()], 127);
371
- if (kept.length >= 128) throw new AddressBookError("limit", `the address book holds at most 128 contacts`);
372
- const keptIds = new Set(kept.map((r) => r.id));
373
- await Promise.all([...this.records.keys()].filter((id) => !keptIds.has(id)).map(async (id) => store.collection(COLLECTION$3).delete(id)));
374
- this.records = new Map(kept.map((r) => [r.id, r]));
354
+ for (const p of this.pending().filter((x) => x.origin.origin === origin)) {
355
+ const waiter = this.waiters.get(p.id);
356
+ this.dropPending(p.id);
357
+ waiter?.reject(userRejectedRequest("the connection was revoked"));
375
358
  }
376
- const now = this.options.now();
377
- const entry = {
378
- id: `contact-${[...this.options.random(8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`,
379
- name,
380
- family: params.family,
381
- address,
382
- ...note === void 0 ? {} : { note },
383
- createdAt: now,
384
- updatedAt: now
385
- };
386
- await this.persist(store, entry);
387
- return entry;
359
+ const session = this.session(origin);
360
+ if (session !== void 0) {
361
+ await store.collection(COLLECTION$4).delete(session.id);
362
+ this.update((s) => {
363
+ const { [session.id]: _gone, ...sessions } = s.sessions;
364
+ return {
365
+ ...s,
366
+ sessions
367
+ };
368
+ });
369
+ }
370
+ this.options.invalidate(origin);
388
371
  }
389
- async rename(id, name) {
372
+ /** Switches within the session's scope; the origin's unfinished requests are void. */
373
+ async setCurrentNetwork(origin, network) {
390
374
  const store = this.requireStore();
391
- const existing = this.requireEntry(id);
392
- const cleaned = cleanName(name);
393
- if (cleaned === void 0) throw new AddressBookError("invalid-input", `name must be 1 to 32 characters`);
394
- const entry = {
395
- ...existing,
396
- name: cleaned,
397
- updatedAt: this.options.now()
398
- };
399
- await this.persist(store, entry);
400
- return entry;
375
+ const session = this.session(origin);
376
+ if (session === void 0 || session.scopes[network] === void 0) throw unauthorized();
377
+ await this.persist(store, {
378
+ ...session,
379
+ currentNetwork: network
380
+ });
381
+ this.options.invalidate(origin);
401
382
  }
402
- /** Sets or clears (empty or `undefined`) the note. */
403
- async setNote(id, note) {
404
- const store = this.requireStore();
405
- const existing = this.requireEntry(id);
406
- const cleaned = this.checkedNote(note);
407
- const { note: _previous, ...rest } = existing;
408
- const entry = {
409
- ...rest,
410
- ...cleaned === void 0 ? {} : { note: cleaned },
411
- updatedAt: this.options.now()
412
- };
413
- await this.persist(store, entry);
414
- return entry;
415
- }
416
- /** Removes the entry, leaving a tombstone so other devices drop it too. */
417
- async remove(id) {
418
- const store = this.requireStore();
419
- this.requireEntry(id);
383
+ async touch(origin) {
384
+ const store = this.engineStore;
385
+ const session = this.session(origin);
386
+ const now = this.options.now();
387
+ if (store === void 0 || session === void 0 || now - session.lastUsedAt < TOUCH_INTERVAL_MS) return;
420
388
  await this.persist(store, {
421
- id,
422
- deleted: true,
423
- updatedAt: this.options.now()
389
+ ...session,
390
+ lastUsedAt: now
424
391
  });
425
392
  }
426
- checkedNote(note) {
427
- const cleaned = cleanNote(note);
428
- if (!cleaned.ok) throw new AddressBookError("invalid-input", `note must be at most 120 characters`);
429
- return cleaned.note;
393
+ /**
394
+ * The host switched account group: every session's accounts become the new group's addresses. A
395
+ * session that cannot get one (no address derivable) is left with an empty list and has to be
396
+ * authorized again.
397
+ */
398
+ async setActiveGroup(walletId, groupIndex) {
399
+ const store = this.requireStore();
400
+ const group = {
401
+ walletId,
402
+ groupIndex
403
+ };
404
+ this.group = group;
405
+ await Promise.all(Object.values(this.getState().sessions).map(async (session) => {
406
+ const entries = await Promise.all(Object.entries(session.scopes).map(async ([network, scope]) => {
407
+ if (scope.accounts.length === 0) return [network, scope];
408
+ try {
409
+ const address = await this.options.evmAddress(group, network);
410
+ return [network, {
411
+ ...scope,
412
+ accounts: [caip10(network, address.toLowerCase())]
413
+ }];
414
+ } catch (error) {
415
+ this.options.logger.warn("connection account unavailable in the new group", error);
416
+ return [network, {
417
+ ...scope,
418
+ accounts: []
419
+ }];
420
+ }
421
+ }));
422
+ await this.persist(store, {
423
+ ...session,
424
+ scopes: Object.fromEntries(entries)
425
+ });
426
+ }));
430
427
  }
431
- requireEntry(id) {
432
- const entry = this.getState().entries[id];
433
- if (entry === void 0) throw new AddressBookError("not-found", `unknown contact ${id}`);
434
- return entry;
428
+ dropPending(id) {
429
+ this.waiters.delete(id);
430
+ this.update((s) => {
431
+ const { [id]: _gone, ...pending } = s.pending;
432
+ return {
433
+ ...s,
434
+ pending
435
+ };
436
+ });
435
437
  }
436
- async persist(store, record) {
437
- await store.collection(COLLECTION$3).put(record.id, new TextEncoder().encode(JSON.stringify(record)));
438
- this.records.set(record.id, record);
439
- this.update(() => ({ entries: entriesOf([...this.records.values()]) }));
438
+ async persist(store, session) {
439
+ try {
440
+ await store.collection(COLLECTION$4).put(session.id, new TextEncoder().encode(JSON.stringify(session)));
441
+ } catch (error) {
442
+ throw internalError(error instanceof Error ? error.message : String(error));
443
+ }
444
+ this.update((s) => ({
445
+ ...s,
446
+ sessions: {
447
+ ...s.sessions,
448
+ [session.id]: session
449
+ }
450
+ }));
440
451
  }
441
452
  requireStore() {
442
- if (this.engineStore === void 0) throw new AddressBookError("locked", "the address book requires an unlocked vault");
453
+ if (this.engineStore === void 0) throw userRejectedRequest("the wallet is locked");
443
454
  return this.engineStore;
444
455
  }
445
456
  };
446
- //#endregion
447
- //#region src/spark/errors.ts
457
+ // src/addresses/normalize.ts
458
+ /**
459
+ * The canonical form of `address` for its family — EIP-55 for EVM, the re-encoded form for bitcoin
460
+ * (accepted on any known bitcoin network, since a contact is not tied to the wallet's network) and
461
+ * Spark (identity key only, no invoice fields) — or `undefined` when it is not a valid address.
462
+ */
463
+ function normalizeAddress(family, address) {
464
+ try {
465
+ if (family === "evm") return isEvmAddress(address) ? checksumEvmAddress(address) : void 0;
466
+ if (family === "solana") return isSolanaAddress(address) ? address : void 0;
467
+ if (family === "spark") {
468
+ const decoded = decodeSparkAddress(address);
469
+ return encodeSparkAddress({
470
+ network: decoded.network,
471
+ identityPublicKey: decoded.identityPublicKey
472
+ });
473
+ }
474
+ for (const network of Object.values(BITCOIN_NETWORKS)) try {
475
+ const codec = Address(network.params);
476
+ return codec.encode(codec.decode(address));
477
+ } catch {}
478
+ return;
479
+ } catch {
480
+ return;
481
+ }
482
+ }
483
+ // src/spark/errors.ts
448
484
  var SparkControllerError = class extends Error {
449
485
  category;
450
486
  constructor(category, message, cause) {
@@ -463,8 +499,7 @@ function categorize(error) {
463
499
  return "internal";
464
500
  }
465
501
  const feeCheck = (amountSats, maxFeeSats) => maxFeeSats !== void 0 && maxFeeSats >= amountSats ? ["fee-exceeds-amount"] : [];
466
- //#endregion
467
- //#region src/payments/errors.ts
502
+ // src/payments/errors.ts
468
503
  var PaymentError = class extends Error {
469
504
  category;
470
505
  constructor(category, message, cause) {
@@ -473,8 +508,7 @@ var PaymentError = class extends Error {
473
508
  this.category = category;
474
509
  }
475
510
  };
476
- //#endregion
477
- //#region src/payments/pay.ts
511
+ // src/payments/pay.ts
478
512
  const lightningDetails = (invoice) => {
479
513
  const decoded = decodeBolt11(invoice);
480
514
  return {
@@ -688,8 +722,7 @@ async function withdrawLnurl(context, params) {
688
722
  invoice
689
723
  };
690
724
  }
691
- //#endregion
692
- //#region src/payments/zap.ts
725
+ // src/payments/zap.ts
693
726
  /** Everything up to payment: resolve LNURL, sign the 9734, request the invoice. */
694
727
  async function prepareZap(params, context) {
695
728
  const amountMsat = params.amountSats * 1e3;
@@ -793,8 +826,13 @@ const lightningInvoiceDetails = (invoice) => ({ payee: bytesToHex(decodeBolt11(i
793
826
  function categorizePayment(error) {
794
827
  return categorize(error) === "network" ? "network" : "rejected";
795
828
  }
796
- //#endregion
797
- //#region src/transactions/route.ts
829
+ // src/spark/l1.ts
830
+ /** The bitcoin network each Spark network settles on; deposits and exits must agree with it. */
831
+ const SPARK_L1_NETWORK = {
832
+ mainnet: "bip122:000000000019d6689c085ae165831e93",
833
+ regtest: "bip122:0f9188f13cb7b2c71f2a335e3a4fc328"
834
+ };
835
+ // src/transactions/route.ts
798
836
  const hexId = (prefix, random) => `${prefix}-${[...random(8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
799
837
  const newId = (random) => hexId("tx", random);
800
838
  const newRequestId = (random) => hexId("req", random);
@@ -815,8 +853,23 @@ const replacementStaleReason = (ctx, originalId) => {
815
853
  if (original?.state !== "submitted" && original?.state !== "pending") return "original transaction already finished — nothing to replace";
816
854
  return Object.values(txs).some((t) => t.replaces === originalId && LIVE_REPLACEMENT_STATES.has(t.state)) ? "original transaction was already replaced — start over" : void 0;
817
855
  };
818
- //#endregion
819
- //#region src/requests/checks.ts
856
+ const submitTx = async (ctx, tx, hash) => {
857
+ const tracked = await ctx.transition(tx, {
858
+ state: "submitted",
859
+ hash,
860
+ submittedAt: ctx.options.now()
861
+ });
862
+ ctx.track(tracked.id, hash);
863
+ return tracked;
864
+ };
865
+ const failTx = async (ctx, tx, error) => {
866
+ const failed = tx.state === "signed" ? "failed" : "rejected";
867
+ await ctx.transition(tx, {
868
+ state: failed,
869
+ error: errorMessage$1(error)
870
+ });
871
+ };
872
+ // src/requests/checks.ts
820
873
  /** Two reads of one fee differ by more than this share of the smaller one. */
821
874
  const FEE_MISMATCH_PERCENT = 25n;
822
875
  /** A fee ceiling above this multiple of the independent estimate is flagged. */
@@ -913,18 +966,20 @@ function assessRecipients(family, recipients, known) {
913
966
  }
914
967
  return [...found.values()].sort((a, b) => Number(b.level === "danger") - Number(a.level === "danger"));
915
968
  }
916
- //#endregion
917
- //#region src/requests/sign-log.ts
969
+ // src/requests/sign-log.ts
918
970
  const SIGN_LOG_LIMIT = 1e3;
919
- const COLLECTION$2 = "sign-log";
920
- const isObject$11 = (v) => typeof v === "object" && v !== null;
971
+ const COLLECTION$3 = "sign-log";
972
+ const isObject$13 = (v) => typeof v === "object" && v !== null;
921
973
  const METHODS = /* @__PURE__ */ new Set([
922
974
  "send",
923
975
  "speed-up",
924
976
  "cancel",
925
977
  "cpfp",
926
978
  "pay",
927
- "zap"
979
+ "zap",
980
+ "evm-sign",
981
+ "spark-deposit",
982
+ "spark-exit"
928
983
  ]);
929
984
  const OUTCOMES = /* @__PURE__ */ new Set([
930
985
  "submitted",
@@ -939,12 +994,13 @@ const FAMILIES = /* @__PURE__ */ new Set([
939
994
  "bitcoin"
940
995
  ]);
941
996
  const isAccount = (value) => {
942
- if (!isObject$11(value)) return false;
997
+ if (!isObject$13(value)) return false;
943
998
  if (value["kind"] === "hd") return typeof value["walletId"] === "string" && typeof value["groupIndex"] === "number" && typeof value["template"] === "string";
944
999
  if (value["kind"] === "imported") return typeof value["keyId"] === "string" && FAMILIES.has(value["family"]);
945
1000
  return value["kind"] === void 0 && typeof value["walletId"] === "string" && typeof value["groupIndex"] === "number" && typeof value["network"] === "string";
946
1001
  };
947
- const isEntry = (value) => isObject$11(value) && typeof value["id"] === "string" && typeof value["at"] === "number" && METHODS.has(value["method"]) && OUTCOMES.has(value["outcome"]) && isAccount(value["account"]);
1002
+ const isOrigin = (value) => isObject$13(value) && (value["kind"] === "internal" || value["kind"] === "browser" && typeof value["origin"] === "string");
1003
+ const isEntry = (value) => isObject$13(value) && typeof value["id"] === "string" && typeof value["at"] === "number" && isOrigin(value["origin"]) && METHODS.has(value["method"]) && OUTCOMES.has(value["outcome"]) && isAccount(value["account"]);
948
1004
  /** Sortable key: time first, request id as the tiebreak. */
949
1005
  const keyOf = (entry) => `${String(entry.at).padStart(16, "0")}-${entry.id}`;
950
1006
  function createSignLog(logger) {
@@ -962,14 +1018,14 @@ function createSignLog(logger) {
962
1018
  logger.warn(`sign log entry for request ${entry.id} (${entry.outcome}) not written: vault locked`);
963
1019
  return;
964
1020
  }
965
- const collection = current.collection(COLLECTION$2);
1021
+ const collection = current.collection(COLLECTION$3);
966
1022
  await collection.put(keyOf(entry), new TextEncoder().encode(JSON.stringify(entry)));
967
1023
  const keys = [...await collection.list()].sort();
968
1024
  await Promise.all(keys.slice(0, Math.max(0, keys.length - SIGN_LOG_LIMIT)).map(async (k) => collection.delete(k)));
969
1025
  },
970
1026
  list: async () => {
971
1027
  if (store === void 0) return [];
972
- const collection = store.collection(COLLECTION$2);
1028
+ const collection = store.collection(COLLECTION$3);
973
1029
  const keys = [...await collection.list()].sort();
974
1030
  const rows = await Promise.all(keys.map(async (k) => collection.get(k)));
975
1031
  const entries = [];
@@ -982,30 +1038,39 @@ function createSignLog(logger) {
982
1038
  },
983
1039
  clear: async () => {
984
1040
  if (store === void 0) return;
985
- const collection = store.collection(COLLECTION$2);
1041
+ const collection = store.collection(COLLECTION$3);
986
1042
  const keys = [...await collection.list()].sort();
987
1043
  await Promise.all(keys.map(async (k) => collection.delete(k)));
988
1044
  }
989
1045
  };
990
1046
  }
991
- //#endregion
992
- //#region src/requests/controller.ts
993
- /**
994
- * Request pipeline for internal sends, speed-ups, cancellations and CPFP: `prepare` builds,
995
- * estimates and cross-checks without signing; `approve(id, method, verify)` is the only way to
996
- * sign, and only for a live, unexpired request of the right method; locking invalidates everything
997
- * that has not finished. The unsigned content stays in a private table, never in the state
998
- * snapshot, and cannot change after prepare.
999
- */
1047
+ // src/requests/controller.ts
1048
+ /** A request that ended without a result: `stage` says how, `message` is the failure if any. */
1049
+ var RequestEndedError = class extends Error {
1050
+ stage;
1051
+ constructor(stage, message) {
1052
+ super(message ?? `request ${stage}`);
1053
+ this.name = "RequestEndedError";
1054
+ this.stage = stage;
1055
+ }
1056
+ };
1057
+ /** The origin already has a request in flight; a dApp gets one at a time. */
1058
+ var OriginBusyError = class extends Error {
1059
+ constructor(origin) {
1060
+ super(`${origin} already has an unfinished request`);
1061
+ this.name = "OriginBusyError";
1062
+ }
1063
+ };
1000
1064
  /** Requests expire this long after creation. */
1001
1065
  const REQUEST_TTL_MS = 6e5;
1002
- const isSparkInput = (input) => input.method === "pay" || input.method === "zap";
1066
+ const originKey = (origin) => `origin:${origin.origin}`;
1003
1067
  const LIVE = /* @__PURE__ */ new Set(["awaiting-approval", "signing"]);
1004
1068
  const errorMessage = (error) => error instanceof Error ? error.message : String(error);
1005
1069
  var RequestController = class extends Controller {
1006
1070
  options;
1007
1071
  entries = /* @__PURE__ */ new Map();
1008
1072
  log;
1073
+ settlers = /* @__PURE__ */ new Map();
1009
1074
  /** Original transaction id → the unfinished request replacing or accelerating it. */
1010
1075
  targets = /* @__PURE__ */ new Map();
1011
1076
  locked = true;
@@ -1024,6 +1089,7 @@ var RequestController = class extends Controller {
1024
1089
  this.locked = false;
1025
1090
  this.entries.clear();
1026
1091
  this.targets.clear();
1092
+ this.settlers.clear();
1027
1093
  this.update(() => ({ requests: {} }));
1028
1094
  this.log.open(await this.options.openStore());
1029
1095
  }
@@ -1038,6 +1104,10 @@ var RequestController = class extends Controller {
1038
1104
  for (const [id, request] of Object.entries(this.getState().requests)) if (LIVE.has(request.stage)) this.finish(id, "invalidated");
1039
1105
  this.log.close();
1040
1106
  }
1107
+ /** A page navigated away, was revoked or changed network: its unfinished requests are void. */
1108
+ invalidateOrigin(origin) {
1109
+ for (const [id, request] of Object.entries(this.getState().requests)) if (LIVE.has(request.stage) && request.origin.kind === "browser" && request.origin.origin === origin) this.finish(id, "invalidated");
1110
+ }
1041
1111
  /** The signature record, newest first. */
1042
1112
  async signLog() {
1043
1113
  return this.log.list();
@@ -1049,7 +1119,12 @@ var RequestController = class extends Controller {
1049
1119
  if (this.locked) throw new Error("requests require an unlocked vault");
1050
1120
  const { session } = this;
1051
1121
  const id = newRequestId(this.options.random);
1052
- const target = input.method === "send" || isSparkInput(input) ? void 0 : input.txId;
1122
+ const target = "txId" in input ? input.txId : void 0;
1123
+ if (input.method === "evm-sign") {
1124
+ const key = originKey(input.origin);
1125
+ if (this.targets.has(key)) throw new OriginBusyError(input.origin.origin);
1126
+ this.targets.set(key, id);
1127
+ }
1053
1128
  if (target !== void 0) {
1054
1129
  if (this.targets.has(target)) throw new Error(`transaction ${target} already has an unfinished replacement request`);
1055
1130
  this.targets.set(target, id);
@@ -1070,7 +1145,7 @@ var RequestController = class extends Controller {
1070
1145
  const createdAt = this.options.now();
1071
1146
  const request = {
1072
1147
  id,
1073
- origin: { kind: "internal" },
1148
+ origin: input.method === "evm-sign" ? input.origin : { kind: "internal" },
1074
1149
  method: input.method,
1075
1150
  account: built.account,
1076
1151
  network: built.network,
@@ -1091,8 +1166,57 @@ var RequestController = class extends Controller {
1091
1166
  stopTimer
1092
1167
  });
1093
1168
  this.put(request);
1169
+ this.openSettler(id);
1094
1170
  return request;
1095
1171
  }
1172
+ /**
1173
+ * Resolves with what `approve` returned, or rejects with `RequestEndedError` when the request
1174
+ * ends any other way — how a dApp's pending call learns the outcome.
1175
+ */
1176
+ async outcome(id) {
1177
+ const settler = this.settlers.get(id);
1178
+ if (settler === void 0) throw new Error(`unknown request ${id}`);
1179
+ return settler.promise;
1180
+ }
1181
+ openSettler(id) {
1182
+ const holder = {};
1183
+ const promise = new Promise((resolve, reject) => {
1184
+ holder.resolve = resolve;
1185
+ holder.reject = reject;
1186
+ });
1187
+ Promise.allSettled([promise]);
1188
+ this.settlers.set(id, {
1189
+ promise,
1190
+ resolve: (value) => holder.resolve?.(value),
1191
+ reject: (error) => holder.reject?.(error)
1192
+ });
1193
+ }
1194
+ settle(id, result) {
1195
+ const settler = this.settlers.get(id);
1196
+ if (settler === void 0) return;
1197
+ if (result instanceof Error) settler.reject(result);
1198
+ else settler.resolve(result);
1199
+ }
1200
+ /** The bitcoin network `account`'s Spark network settles on; any other pairing is refused. */
1201
+ l1Network(account) {
1202
+ const network = SPARK_L1_NETWORK[account.network];
1203
+ if (network === void 0) throw new Error(`unknown Spark network ${account.network}`);
1204
+ return network;
1205
+ }
1206
+ sparkBuilt(account, action) {
1207
+ return {
1208
+ account,
1209
+ network: action.display.network,
1210
+ display: action.display,
1211
+ checks: collectChecks(action.checks),
1212
+ risk: this.sparkRisk(action.display),
1213
+ run: {
1214
+ staleReason: () => action.staleReason(this.options.now()),
1215
+ submit: async (verify) => action.execute(this.options.spark, verify),
1216
+ release: () => void 0
1217
+ }
1218
+ };
1219
+ }
1096
1220
  async build(input, id) {
1097
1221
  if (input.method === "pay" || input.method === "zap") {
1098
1222
  const action = input.method === "pay" ? await preparePay({
@@ -1102,17 +1226,60 @@ var RequestController = class extends Controller {
1102
1226
  maxFeeSats: input.maxFeeSats,
1103
1227
  now: this.options.now
1104
1228
  });
1229
+ return this.sparkBuilt(input.account, action);
1230
+ }
1231
+ if (input.method === "spark-deposit") {
1232
+ const l1 = this.l1Network(input.account);
1233
+ if (input.from.kind !== "hd" || input.from.walletId !== input.account.walletId) throw new Error("a Spark deposit must come from the same wallet");
1234
+ const to = await this.options.spark.newDepositAddress(input.account);
1235
+ const prepared = await this.options.transactions.prepareSend({
1236
+ account: input.from,
1237
+ network: l1,
1238
+ to,
1239
+ amount: BigInt(input.amountSats),
1240
+ fee: input.fee
1241
+ }, id, {
1242
+ walletId: input.account.walletId,
1243
+ groupIndex: input.account.groupIndex,
1244
+ network: input.account.network
1245
+ });
1105
1246
  return {
1247
+ account: prepared.account,
1248
+ network: prepared.network,
1249
+ display: prepared.intent,
1250
+ checks: collectChecks([prepared.intent.verifiability === "full" ? void 0 : "decode-partial", ...prepared.checks]),
1251
+ risk: [],
1252
+ run: prepared
1253
+ };
1254
+ }
1255
+ if (input.method === "spark-exit") {
1256
+ const l1 = this.l1Network(input.account);
1257
+ const address = await this.options.transactions.nextReceiveAddress(input.account.walletId, input.account.groupIndex, l1);
1258
+ const action = await preparePay({
1259
+ fetch: this.options.fetch,
1260
+ now: this.options.now
1261
+ }, {
1106
1262
  account: input.account,
1107
- network: action.display.network,
1108
- display: action.display,
1109
- checks: collectChecks(action.checks),
1110
- risk: this.sparkRisk(action.display),
1111
- run: {
1112
- staleReason: () => action.staleReason(this.options.now()),
1113
- submit: async (verify) => action.execute(this.options.spark, verify),
1114
- release: () => void 0
1115
- }
1263
+ option: {
1264
+ kind: "bitcoin",
1265
+ address
1266
+ },
1267
+ amountSats: input.amountSats,
1268
+ maxFeeSats: input.maxFeeSats,
1269
+ ...input.speed === void 0 ? {} : { speed: input.speed }
1270
+ });
1271
+ return this.sparkBuilt(input.account, action);
1272
+ }
1273
+ if (input.method === "evm-sign") {
1274
+ const prepared = await this.options.transactions.prepareDapp(input.account, input.request);
1275
+ const isTransaction = input.request.kind === "evm.transaction";
1276
+ return {
1277
+ account: prepared.account,
1278
+ network: prepared.network,
1279
+ display: prepared.intent,
1280
+ checks: collectChecks([isTransaction && prepared.intent.verifiability !== "full" ? "decode-partial" : void 0, ...prepared.checks]),
1281
+ risk: isTransaction ? this.chainRisk(prepared.intent) : [],
1282
+ run: prepared
1116
1283
  };
1117
1284
  }
1118
1285
  const prepared = input.method === "send" ? await this.options.transactions.prepareSend(input, id) : await this.options.transactions.prepareReplacement(input, id);
@@ -1165,6 +1332,7 @@ var RequestController = class extends Controller {
1165
1332
  this.put(done);
1166
1333
  this.record(done, "submitted");
1167
1334
  } else this.record(request, "submitted");
1335
+ this.settle(id, result);
1168
1336
  return result;
1169
1337
  } catch (error) {
1170
1338
  if (session === this.session) this.finish(id, this.failureStage(entry.prepared, error), errorMessage(error));
@@ -1226,6 +1394,7 @@ var RequestController = class extends Controller {
1226
1394
  };
1227
1395
  this.put(ended);
1228
1396
  this.record(ended, stage, error);
1397
+ this.settle(id, new RequestEndedError(stage, error));
1229
1398
  }
1230
1399
  }
1231
1400
  /** Appends the final stage to the signature record; a failed write is a warning, not an error. */
@@ -1245,36 +1414,592 @@ var RequestController = class extends Controller {
1245
1414
  outcome,
1246
1415
  ...error === void 0 ? {} : { error }
1247
1416
  };
1248
- this.appendLog(entry);
1417
+ this.appendLog(entry);
1418
+ }
1419
+ async appendLog(entry) {
1420
+ try {
1421
+ await this.log.append(entry);
1422
+ } catch (error) {
1423
+ this.options.logger.warn("sign log write failed", error);
1424
+ }
1425
+ }
1426
+ /**
1427
+ * Addresses the wallet already knows for a family: contacts, plus recipients of tracked chain
1428
+ * sends (Spark payments are not tracked).
1429
+ */
1430
+ knownAddresses(family) {
1431
+ const contacts = Object.values(this.options.addressBook.getState().entries).filter((e) => e.family === family).map((e) => e.address);
1432
+ if (family === "spark") return contacts;
1433
+ const paid = Object.values(this.options.transactions.getState().txs).filter((tx) => tx.family === family && (tx.state === "submitted" || tx.state === "pending" || tx.state === "confirmed")).flatMap((tx) => tx.intent?.outflows.map((o) => o.to) ?? []);
1434
+ return [...contacts, ...paid];
1435
+ }
1436
+ chainRisk(intent) {
1437
+ return assessRecipients(intent.family, intent.outflows.map((o) => o.to), this.knownAddresses(intent.family));
1438
+ }
1439
+ /** Only a Spark transfer has a recipient to check; Lightning and exits are not assessed. */
1440
+ sparkRisk(display) {
1441
+ if (display.kind !== "transfer") return [];
1442
+ return assessRecipients("spark", [normalizeAddress("spark", display.receiver) ?? display.receiver], this.knownAddresses("spark"));
1443
+ }
1444
+ };
1445
+ // src/connect/eip1193.ts
1446
+ /**
1447
+ * Platform-neutral EIP-1193 provider logic: it knows nothing about WebViews. The host hands it a
1448
+ * page's origin (computed from the WebView's top-level URL, never from the page) and a JSON-RPC
1449
+ * call; signing goes through the request pipeline and waits for the host's approval by id.
1450
+ */
1451
+ /** Refused outright, whatever the session. */
1452
+ const REFUSED = /* @__PURE__ */ new Set([
1453
+ "eth_sign",
1454
+ "eth_signTypedData",
1455
+ "eth_signTypedData_v3",
1456
+ "eth_signTransaction",
1457
+ "eth_sendRawTransaction"
1458
+ ]);
1459
+ /** Planned for later layers; answered `4200` until then. */
1460
+ const DEFERRED = /* @__PURE__ */ new Set([
1461
+ "wallet_addEthereumChain",
1462
+ "wallet_watchAsset",
1463
+ "wallet_sendCalls",
1464
+ "wallet_getCallsStatus",
1465
+ "wallet_getCapabilities",
1466
+ "wallet_showCallsStatus"
1467
+ ]);
1468
+ const READ_ONLY = new Set(EVM_RPC_METHODS.filter((m) => m !== "eth_sendRawTransaction" && m !== "eth_chainId"));
1469
+ const hexChainId = (network) => `0x${(evmChainId(network) ?? 0).toString(16)}`;
1470
+ const isObject$12 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1471
+ const paramList = (params) => {
1472
+ if (params === void 0) return [];
1473
+ if (!Array.isArray(params)) throw invalidParams("params must be an array");
1474
+ return params;
1475
+ };
1476
+ const textBytes = (value) => {
1477
+ if (typeof value !== "string") throw invalidParams("message must be a string");
1478
+ if (/^0x(?:[0-9a-fA-F]{2})*$/.test(value)) {
1479
+ const bytes = hexToBytes(value.slice(2));
1480
+ try {
1481
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1482
+ } catch {
1483
+ throw new ProviderError(4200, "blind signing is not enabled: the message is not UTF-8 text");
1484
+ }
1485
+ return bytes;
1486
+ }
1487
+ return new TextEncoder().encode(value);
1488
+ };
1489
+ const quantityOf = (value, field, fallback) => {
1490
+ if (value === void 0) return fallback;
1491
+ if (typeof value !== "string" || !/^0x[0-9a-fA-F]+$/.test(value)) throw invalidParams(`${field} must be a 0x quantity`);
1492
+ return value;
1493
+ };
1494
+ var Eip1193Handler = class {
1495
+ options;
1496
+ listeners = /* @__PURE__ */ new Set();
1497
+ unsubscribe;
1498
+ constructor(options) {
1499
+ this.options = options;
1500
+ }
1501
+ /** Events for the host to forward to pages; derived from changes to the sessions. */
1502
+ subscribe(listener) {
1503
+ this.listeners.add(listener);
1504
+ if (this.unsubscribe === void 0) {
1505
+ let previous = this.options.connections.getState().sessions;
1506
+ this.unsubscribe = this.options.connections.store.subscribe((state) => {
1507
+ for (const event of diffSessions(previous, state.sessions)) for (const l of this.listeners) l(event);
1508
+ previous = state.sessions;
1509
+ });
1510
+ }
1511
+ return () => {
1512
+ this.listeners.delete(listener);
1513
+ if (this.listeners.size === 0) {
1514
+ this.unsubscribe?.();
1515
+ this.unsubscribe = void 0;
1516
+ }
1517
+ };
1518
+ }
1519
+ async handle(origin, request) {
1520
+ const { connections } = this.options;
1521
+ connections.checkOrigin(origin);
1522
+ const { method } = request;
1523
+ const params = paramList(request.params);
1524
+ if (REFUSED.has(method) || DEFERRED.has(method)) throw unsupportedMethod(method);
1525
+ switch (method) {
1526
+ case "eth_accounts": return connections.accounts(origin);
1527
+ case "eth_chainId": return hexChainId(connections.session(origin)?.currentNetwork ?? this.options.defaultNetwork());
1528
+ case "eth_requestAccounts": return this.requestAccounts(origin);
1529
+ case "wallet_requestPermissions":
1530
+ await this.requestAccounts(origin);
1531
+ return this.permissions(origin);
1532
+ case "wallet_getPermissions": return this.permissions(origin);
1533
+ case "wallet_revokePermissions":
1534
+ await connections.revoke(origin);
1535
+ return null;
1536
+ case "wallet_switchEthereumChain": return this.switchChain(origin, params[0]);
1537
+ case "personal_sign": return this.personalSign(origin, params);
1538
+ case "eth_signTypedData_v4": return this.typedData(origin, params);
1539
+ case "eth_sendTransaction": return this.sendTransaction(origin, params[0]);
1540
+ }
1541
+ if (READ_ONLY.has(method)) return this.read(origin, method, params);
1542
+ throw new ProviderError(-32601, `${method} does not exist`);
1543
+ }
1544
+ async requestAccounts(origin) {
1545
+ const { connections } = this.options;
1546
+ if (connections.accounts(origin).length === 0) await connections.request(origin, "connect");
1547
+ await connections.touch(origin);
1548
+ return connections.accounts(origin);
1549
+ }
1550
+ permissions(origin) {
1551
+ const accounts = this.options.connections.accounts(origin);
1552
+ if (accounts.length === 0) return [];
1553
+ return [{
1554
+ parentCapability: "eth_accounts",
1555
+ invoker: origin,
1556
+ caveats: [{
1557
+ type: "restrictReturnedAccounts",
1558
+ value: accounts
1559
+ }]
1560
+ }];
1561
+ }
1562
+ async switchChain(origin, arg) {
1563
+ const { connections } = this.options;
1564
+ const chainId = isObject$12(arg) ? arg["chainId"] : void 0;
1565
+ if (typeof chainId !== "string" || !/^0x[0-9a-fA-F]+$/.test(chainId)) throw invalidParams("chainId must be a 0x quantity");
1566
+ const network = `eip155:${Number.parseInt(chainId, 16)}`;
1567
+ if (!connections.isEnabled(network)) throw unrecognizedChain(`chain ${chainId} is not enabled`);
1568
+ const session = connections.session(origin);
1569
+ if (session === void 0) throw unauthorized();
1570
+ if (session.scopes[network] === void 0) await connections.request(origin, "network", network);
1571
+ else if (session.currentNetwork !== network) await connections.setCurrentNetwork(origin, network);
1572
+ return null;
1573
+ }
1574
+ authorizedAccount(session, address) {
1575
+ const accounts = this.options.connections.accounts(session.origin.origin);
1576
+ const wanted = typeof address === "string" ? address.toLowerCase() : accounts[0];
1577
+ if (wanted === void 0 || !accounts.some((a) => a.toLowerCase() === wanted.toLowerCase())) throw unauthorized("account not authorized");
1578
+ return wanted;
1579
+ }
1580
+ async personalSign(origin, params) {
1581
+ const session = this.signingSession(origin, "personal_sign");
1582
+ this.authorizedAccount(session, params[1]);
1583
+ const message = textBytes(params[0]);
1584
+ return this.sign(origin, session, (account) => ({
1585
+ kind: "evm.personal-message",
1586
+ network: session.currentNetwork,
1587
+ account,
1588
+ message
1589
+ }));
1590
+ }
1591
+ async typedData(origin, params) {
1592
+ const session = this.signingSession(origin, "eth_signTypedData_v4");
1593
+ this.authorizedAccount(session, params[0]);
1594
+ const [, source] = params;
1595
+ const typedData = typeof source === "string" ? source : JSON.stringify(source);
1596
+ return this.sign(origin, session, (account) => ({
1597
+ kind: "evm.typed-data",
1598
+ network: session.currentNetwork,
1599
+ account,
1600
+ typedData
1601
+ }));
1602
+ }
1603
+ async sendTransaction(origin, arg) {
1604
+ const session = this.signingSession(origin, "eth_sendTransaction");
1605
+ if (!isObject$12(arg)) throw invalidParams("transaction must be an object");
1606
+ this.authorizedAccount(session, arg["from"]);
1607
+ const chainId = evmChainId(session.currentNetwork);
1608
+ if (chainId === void 0) throw internalError("session network is not an EVM network");
1609
+ if (arg["chainId"] !== void 0 && Number(arg["chainId"]) !== chainId) throw invalidParams("transaction chainId does not match the session network");
1610
+ const data = arg["data"] ?? arg["input"] ?? "0x";
1611
+ if (typeof data !== "string") throw invalidParams("data must be a hex string");
1612
+ const { to } = arg;
1613
+ if (to !== void 0 && typeof to !== "string") throw invalidParams("to must be an address");
1614
+ return this.sign(origin, session, (account) => ({
1615
+ kind: "evm.transaction",
1616
+ network: session.currentNetwork,
1617
+ account,
1618
+ tx: {
1619
+ type: "eip1559",
1620
+ chainId,
1621
+ nonce: 0,
1622
+ gas: quantityOf(arg["gas"], "gas", "0x0"),
1623
+ value: quantityOf(arg["value"], "value", "0x0"),
1624
+ data,
1625
+ ...to === void 0 ? {} : { to },
1626
+ accessList: [],
1627
+ maxFeePerGas: "0x0",
1628
+ maxPriorityFeePerGas: "0x0"
1629
+ }
1630
+ }));
1631
+ }
1632
+ signingSession(origin, method) {
1633
+ const { connections } = this.options;
1634
+ const session = connections.session(origin);
1635
+ if (session === void 0) throw unauthorized();
1636
+ return connections.authorize(origin, session.currentNetwork, method);
1637
+ }
1638
+ async sign(origin, session, build) {
1639
+ const { connections, requests } = this.options;
1640
+ const group = connections.activeGroup();
1641
+ if (group === void 0) throw internalError("no account group is selected");
1642
+ const account = {
1643
+ kind: "hd",
1644
+ ...group,
1645
+ template: "evm.bip44"
1646
+ };
1647
+ try {
1648
+ const pending = await requests.prepare({
1649
+ method: "evm-sign",
1650
+ origin: {
1651
+ kind: "browser",
1652
+ origin
1653
+ },
1654
+ account,
1655
+ network: session.currentNetwork,
1656
+ request: build(account)
1657
+ });
1658
+ const result = await requests.outcome(pending.id);
1659
+ await connections.touch(origin);
1660
+ if ("signature" in result && result.signature instanceof Uint8Array) return `0x${bytesToHex(result.signature)}`;
1661
+ if (!("hash" in result) || typeof result.hash !== "string") throw internalError("the transaction has no hash");
1662
+ return result.hash;
1663
+ } catch (error) {
1664
+ throw toProviderError(error);
1665
+ }
1666
+ }
1667
+ async read(origin, method, params) {
1668
+ const { connections, networks } = this.options;
1669
+ const network = connections.session(origin)?.currentNetwork ?? this.options.defaultNetwork();
1670
+ try {
1671
+ return await networks.client(network).transport.request({
1672
+ method,
1673
+ params: [...params]
1674
+ });
1675
+ } catch (error) {
1676
+ throw toProviderError(error);
1677
+ }
1678
+ }
1679
+ };
1680
+ function toProviderError(error) {
1681
+ if (error instanceof ProviderError) return error;
1682
+ if (error instanceof OriginBusyError) return userRejectedRequest(error.message);
1683
+ if (error instanceof RequestEndedError) return error.stage === "failed" ? internalError(error.message) : userRejectedRequest(error.message);
1684
+ if (error instanceof ChainError) return invalidParams(error.message);
1685
+ return internalError(error instanceof Error ? error.message : String(error));
1686
+ }
1687
+ function currentAccounts(session) {
1688
+ return session.scopes[session.currentNetwork]?.accounts.map((a) => a.slice(a.lastIndexOf(":") + 1)) ?? [];
1689
+ }
1690
+ const byOrigin = (sessions) => new Map(Object.values(sessions).map((s) => [s.origin.origin, s]));
1691
+ function diffSessions(before, after) {
1692
+ const events = [];
1693
+ const prev = byOrigin(before);
1694
+ const next = byOrigin(after);
1695
+ for (const [origin, session] of prev) {
1696
+ const now = next.get(origin);
1697
+ if (now === void 0) {
1698
+ events.push({
1699
+ name: "disconnect",
1700
+ origin
1701
+ });
1702
+ continue;
1703
+ }
1704
+ if (now.currentNetwork !== session.currentNetwork) events.push({
1705
+ name: "chainChanged",
1706
+ origin,
1707
+ chainId: hexChainId(now.currentNetwork)
1708
+ });
1709
+ if (currentAccounts(session).join(",") !== currentAccounts(now).join(",")) events.push({
1710
+ name: "accountsChanged",
1711
+ origin,
1712
+ accounts: currentAccounts(now)
1713
+ });
1714
+ }
1715
+ for (const [origin, session] of next) if (!prev.has(origin)) events.push({
1716
+ name: "connect",
1717
+ origin,
1718
+ chainId: hexChainId(session.currentNetwork)
1719
+ });
1720
+ return events;
1721
+ }
1722
+ // src/addresses/errors.ts
1723
+ var AddressBookError = class extends Error {
1724
+ category;
1725
+ constructor(category, message) {
1726
+ super(message);
1727
+ this.name = "AddressBookError";
1728
+ this.category = category;
1729
+ }
1730
+ };
1731
+ // src/addresses/record.ts
1732
+ /**
1733
+ * The address-book record shared by the controller, the sync merge and the metadata document: a
1734
+ * live entry, or a tombstone that carries a deletion to the other devices.
1735
+ */
1736
+ const isTombstone = (record) => "deleted" in record;
1737
+ const isFamily = (v) => v === "evm" || v === "solana" || v === "bitcoin" || v === "spark";
1738
+ const ENTRY_KEYS = /* @__PURE__ */ new Set([
1739
+ "id",
1740
+ "name",
1741
+ "family",
1742
+ "address",
1743
+ "note",
1744
+ "createdAt",
1745
+ "updatedAt"
1746
+ ]);
1747
+ const TOMBSTONE_KEYS = /* @__PURE__ */ new Set([
1748
+ "id",
1749
+ "deleted",
1750
+ "updatedAt"
1751
+ ]);
1752
+ const isObject$11 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1753
+ const isNonNegativeInt$2 = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
1754
+ /** Trimmed name, or `undefined` when it is not 1 to {@link NAME_MAX} characters. */
1755
+ const cleanName = (raw) => {
1756
+ const name = raw.trim();
1757
+ return name.length > 0 && name.length <= 32 ? name : void 0;
1758
+ };
1759
+ /** Trimmed note; an empty one means no note. `undefined` return with `ok: false` when too long. */
1760
+ const cleanNote = (raw) => {
1761
+ const note = raw?.trim();
1762
+ if (note === void 0 || note.length === 0) return {
1763
+ ok: true,
1764
+ note: void 0
1765
+ };
1766
+ return note.length <= 120 ? {
1767
+ ok: true,
1768
+ note
1769
+ } : { ok: false };
1770
+ };
1771
+ const hasOnly = (value, keys) => Object.keys(value).every((k) => keys.has(k));
1772
+ /**
1773
+ * Strict decode with the controller's rules; the address is re-normalized. Anything else is
1774
+ * `undefined`.
1775
+ */
1776
+ function decodeRecord(value) {
1777
+ if (!isObject$11(value) || typeof value["id"] !== "string" || !isNonNegativeInt$2(value["updatedAt"])) return;
1778
+ if ("deleted" in value) return value["deleted"] === true && hasOnly(value, TOMBSTONE_KEYS) ? {
1779
+ id: value["id"],
1780
+ deleted: true,
1781
+ updatedAt: value["updatedAt"]
1782
+ } : void 0;
1783
+ const { family, name, address, note } = value;
1784
+ if (!hasOnly(value, ENTRY_KEYS) || !isFamily(family) || typeof name !== "string" || typeof address !== "string" || !isNonNegativeInt$2(value["createdAt"]) || note !== void 0 && typeof note !== "string") return;
1785
+ const cleanedName = cleanName(name);
1786
+ const normalized = normalizeAddress(family, address);
1787
+ const cleanedNote = cleanNote(note);
1788
+ if (cleanedName === void 0 || normalized === void 0 || !cleanedNote.ok || note !== void 0 && cleanedNote.note === void 0) return;
1789
+ return {
1790
+ id: value["id"],
1791
+ name: cleanedName,
1792
+ family,
1793
+ address: normalized,
1794
+ ...cleanedNote.note === void 0 ? {} : { note: cleanedNote.note },
1795
+ createdAt: value["createdAt"],
1796
+ updatedAt: value["updatedAt"]
1797
+ };
1798
+ }
1799
+ const serialized = (record) => JSON.stringify(record);
1800
+ /** The winner of two versions of one id: larger `updatedAt`, then the larger serialization. */
1801
+ function pickWinner(a, b) {
1802
+ if (a.updatedAt !== b.updatedAt) return a.updatedAt > b.updatedAt ? a : b;
1803
+ return serialized(a) >= serialized(b) ? a : b;
1804
+ }
1805
+ const pruneTombstones = (records, now) => records.filter((r) => !isTombstone(r) || now - r.updatedAt <= 7776e6);
1806
+ /** Newest first, id as the tiebreak, so every device orders (and truncates) the same way. */
1807
+ const newestFirst = (a, b) => b.updatedAt - a.updatedAt || (a.id < b.id ? -1 : 1);
1808
+ /** Drops the oldest tombstones until at most `max` records remain, or no tombstone is left. */
1809
+ function evictTombstones(records, max) {
1810
+ const excess = records.length - max;
1811
+ if (excess <= 0) return records;
1812
+ const oldest = new Set(records.filter(isTombstone).sort((a, b) => a.updatedAt - b.updatedAt || (a.id < b.id ? -1 : 1)).slice(0, excess).map((r) => r.id));
1813
+ return records.filter((r) => !oldest.has(r.id));
1814
+ }
1815
+ /** {@link mergeRecords}, also counting the live entries the cap forced out. */
1816
+ function mergeWithReport(local, remote, now) {
1817
+ const byId = /* @__PURE__ */ new Map();
1818
+ for (const record of [...local, ...remote]) {
1819
+ const existing = byId.get(record.id);
1820
+ byId.set(record.id, existing === void 0 ? record : pickWinner(existing, record));
1821
+ }
1822
+ const live = /* @__PURE__ */ new Map();
1823
+ const kept = [];
1824
+ for (const record of [...pruneTombstones([...byId.values()], now)].sort(newestFirst)) {
1825
+ if (isTombstone(record)) {
1826
+ kept.push(record);
1827
+ continue;
1828
+ }
1829
+ const key = `${record.family}:${record.address}`;
1830
+ if (!live.has(key)) {
1831
+ live.set(key, record);
1832
+ kept.push(record);
1833
+ }
1834
+ }
1835
+ const evicted = evictTombstones(kept, 128);
1836
+ return {
1837
+ records: [...evicted.slice(0, 128)].sort((a, b) => a.id < b.id ? -1 : 1),
1838
+ droppedLive: evicted.slice(128).filter((r) => !isTombstone(r)).length
1839
+ };
1840
+ }
1841
+ // src/addresses/controller.ts
1842
+ /**
1843
+ * Address book: named recipients, validated and normalized per chain family, persisted in the
1844
+ * engine store and cleared from memory on lock. Deletions are kept as tombstones so they reach the
1845
+ * other devices; `syncEntries`/`merge` are the whole surface the metadata sync needs. Request risk
1846
+ * checks treat its addresses as known.
1847
+ */
1848
+ const COLLECTION$2 = "contacts";
1849
+ const entriesOf = (records) => Object.fromEntries(records.flatMap((r) => isTombstone(r) ? [] : [[r.id, r]]));
1850
+ var AddressBookController = class extends Controller {
1851
+ options;
1852
+ engineStore;
1853
+ /** Every record, tombstones included, by id. */
1854
+ records = /* @__PURE__ */ new Map();
1855
+ constructor(options) {
1856
+ super({
1857
+ name: "address-book",
1858
+ defaultState: () => ({ entries: {} })
1859
+ });
1860
+ this.options = options;
1861
+ }
1862
+ async init() {
1863
+ const store = await this.options.openStore();
1864
+ this.engineStore = store;
1865
+ const collection = store.collection(COLLECTION$2);
1866
+ const keys = await collection.list();
1867
+ const rows = await Promise.all(keys.map(async (key) => collection.get(key)));
1868
+ const records = /* @__PURE__ */ new Map();
1869
+ for (const raw of rows) {
1870
+ if (raw === void 0) continue;
1871
+ const record = decodeRecord(JSON.parse(new TextDecoder().decode(raw)));
1872
+ if (record === void 0) this.options.logger.warn("address book record skipped: malformed");
1873
+ else records.set(record.id, record);
1874
+ }
1875
+ this.records = records;
1876
+ this.update(() => ({ entries: entriesOf([...records.values()]) }));
1877
+ }
1878
+ async destroy() {
1879
+ this.lock();
1880
+ await Promise.resolve();
1881
+ }
1882
+ lock() {
1883
+ this.engineStore = void 0;
1884
+ this.records = /* @__PURE__ */ new Map();
1885
+ this.update(() => ({ entries: {} }));
1886
+ }
1887
+ /** Entries and tombstones, for the metadata document. */
1888
+ syncEntries() {
1889
+ return [...this.records.values()].sort((a, b) => a.id < b.id ? -1 : 1);
1890
+ }
1891
+ /**
1892
+ * Merges records from another device (last writer wins, tombstones win over older entries).
1893
+ * Returns whether anything local changed and whether the merged set holds something `remote`
1894
+ * lacks, i.e. whether the remote copy needs a newer publish.
1895
+ */
1896
+ async merge(remote) {
1897
+ const store = this.requireStore();
1898
+ const before = JSON.stringify(this.syncEntries());
1899
+ const { records: merged, droppedLive } = mergeWithReport(this.syncEntries(), remote, this.options.now());
1900
+ if (droppedLive > 0) this.options.logger.warn(`address book sync: ${droppedLive} contacts dropped, over the 128-record cap`);
1901
+ const remoteSorted = JSON.stringify([...remote].sort((a, b) => a.id < b.id ? -1 : 1));
1902
+ const localNewer = JSON.stringify(merged) !== remoteSorted;
1903
+ if (JSON.stringify(merged) === before) return {
1904
+ changed: false,
1905
+ localNewer
1906
+ };
1907
+ const collection = store.collection(COLLECTION$2);
1908
+ const next = new Map(merged.map((r) => [r.id, r]));
1909
+ await Promise.all([...merged.map(async (r) => collection.put(r.id, new TextEncoder().encode(JSON.stringify(r)))), ...[...this.records.keys()].filter((id) => !next.has(id)).map(async (id) => collection.delete(id))]);
1910
+ this.records = next;
1911
+ this.update(() => ({ entries: entriesOf(merged) }));
1912
+ return {
1913
+ changed: true,
1914
+ localNewer
1915
+ };
1916
+ }
1917
+ async add(params) {
1918
+ const store = this.requireStore();
1919
+ const name = cleanName(params.name);
1920
+ if (name === void 0) throw new AddressBookError("invalid-input", `name must be 1 to 32 characters`);
1921
+ const note = this.checkedNote(params.note);
1922
+ const address = normalizeAddress(params.family, params.address.trim());
1923
+ if (address === void 0) throw new AddressBookError("invalid-input", `not a valid ${params.family} address`);
1924
+ if (Object.values(this.getState().entries).some((e) => e.family === params.family && e.address === address)) throw new AddressBookError("duplicate", "this address is already in the address book");
1925
+ if (this.records.size >= 128) {
1926
+ const kept = evictTombstones([...this.records.values()], 127);
1927
+ if (kept.length >= 128) throw new AddressBookError("limit", `the address book holds at most 128 contacts`);
1928
+ const keptIds = new Set(kept.map((r) => r.id));
1929
+ await Promise.all([...this.records.keys()].filter((id) => !keptIds.has(id)).map(async (id) => store.collection(COLLECTION$2).delete(id)));
1930
+ this.records = new Map(kept.map((r) => [r.id, r]));
1931
+ }
1932
+ const now = this.options.now();
1933
+ const entry = {
1934
+ id: `contact-${[...this.options.random(8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`,
1935
+ name,
1936
+ family: params.family,
1937
+ address,
1938
+ ...note === void 0 ? {} : { note },
1939
+ createdAt: now,
1940
+ updatedAt: now
1941
+ };
1942
+ await this.persist(store, entry);
1943
+ return entry;
1944
+ }
1945
+ async rename(id, name) {
1946
+ const store = this.requireStore();
1947
+ const existing = this.requireEntry(id);
1948
+ const cleaned = cleanName(name);
1949
+ if (cleaned === void 0) throw new AddressBookError("invalid-input", `name must be 1 to 32 characters`);
1950
+ const entry = {
1951
+ ...existing,
1952
+ name: cleaned,
1953
+ updatedAt: this.options.now()
1954
+ };
1955
+ await this.persist(store, entry);
1956
+ return entry;
1957
+ }
1958
+ /** Sets or clears (empty or `undefined`) the note. */
1959
+ async setNote(id, note) {
1960
+ const store = this.requireStore();
1961
+ const existing = this.requireEntry(id);
1962
+ const cleaned = this.checkedNote(note);
1963
+ const { note: _previous, ...rest } = existing;
1964
+ const entry = {
1965
+ ...rest,
1966
+ ...cleaned === void 0 ? {} : { note: cleaned },
1967
+ updatedAt: this.options.now()
1968
+ };
1969
+ await this.persist(store, entry);
1970
+ return entry;
1249
1971
  }
1250
- async appendLog(entry) {
1251
- try {
1252
- await this.log.append(entry);
1253
- } catch (error) {
1254
- this.options.logger.warn("sign log write failed", error);
1255
- }
1972
+ /** Removes the entry, leaving a tombstone so other devices drop it too. */
1973
+ async remove(id) {
1974
+ const store = this.requireStore();
1975
+ this.requireEntry(id);
1976
+ await this.persist(store, {
1977
+ id,
1978
+ deleted: true,
1979
+ updatedAt: this.options.now()
1980
+ });
1256
1981
  }
1257
- /**
1258
- * Addresses the wallet already knows for a family: contacts, plus recipients of tracked chain
1259
- * sends (Spark payments are not tracked).
1260
- */
1261
- knownAddresses(family) {
1262
- const contacts = Object.values(this.options.addressBook.getState().entries).filter((e) => e.family === family).map((e) => e.address);
1263
- if (family === "spark") return contacts;
1264
- const paid = Object.values(this.options.transactions.getState().txs).filter((tx) => tx.family === family && (tx.state === "submitted" || tx.state === "pending" || tx.state === "confirmed")).flatMap((tx) => tx.intent?.outflows.map((o) => o.to) ?? []);
1265
- return [...contacts, ...paid];
1982
+ checkedNote(note) {
1983
+ const cleaned = cleanNote(note);
1984
+ if (!cleaned.ok) throw new AddressBookError("invalid-input", `note must be at most 120 characters`);
1985
+ return cleaned.note;
1266
1986
  }
1267
- chainRisk(intent) {
1268
- return assessRecipients(intent.family, intent.outflows.map((o) => o.to), this.knownAddresses(intent.family));
1987
+ requireEntry(id) {
1988
+ const entry = this.getState().entries[id];
1989
+ if (entry === void 0) throw new AddressBookError("not-found", `unknown contact ${id}`);
1990
+ return entry;
1269
1991
  }
1270
- /** Only a Spark transfer has a recipient to check; Lightning and exits are not assessed. */
1271
- sparkRisk(display) {
1272
- if (display.kind !== "transfer") return [];
1273
- return assessRecipients("spark", [normalizeAddress("spark", display.receiver) ?? display.receiver], this.knownAddresses("spark"));
1992
+ async persist(store, record) {
1993
+ await store.collection(COLLECTION$2).put(record.id, new TextEncoder().encode(JSON.stringify(record)));
1994
+ this.records.set(record.id, record);
1995
+ this.update(() => ({ entries: entriesOf([...this.records.values()]) }));
1996
+ }
1997
+ requireStore() {
1998
+ if (this.engineStore === void 0) throw new AddressBookError("locked", "the address book requires an unlocked vault");
1999
+ return this.engineStore;
1274
2000
  }
1275
2001
  };
1276
- //#endregion
1277
- //#region src/chains/bitcoin-scan.ts
2002
+ // src/chains/bitcoin-scan.ts
1278
2003
  /**
1279
2004
  * Gap-20 BIP-44/86 account scanning over Esplora — derives /0/* and /1/* from the account xpub,
1280
2005
  * walks each chain in windows until 20 consecutive unused addresses, persists the discovered
@@ -1282,6 +2007,7 @@ var RequestController = class extends Controller {
1282
2007
  * receive/change address.
1283
2008
  */
1284
2009
  const GAP = 20;
2010
+ const COLL = "btc-scan";
1285
2011
  const isObject$10 = (v) => typeof v === "object" && v !== null;
1286
2012
  const isUnknownArray = (v) => Array.isArray(v);
1287
2013
  const asArray = (v) => isUnknownArray(v) ? v : [];
@@ -1334,8 +2060,45 @@ const scanUtxos = async (es, addresses, params) => {
1334
2060
  const candidates = addresses.filter((a) => a.used || a.chain === 1 || a.index < GAP);
1335
2061
  return Promise.all(candidates.map(async (a) => scanAddressUtxos(es, a, params))).then((r) => r.flat());
1336
2062
  };
2063
+ var BitcoinScanStore = class {
2064
+ engineStore;
2065
+ async unlock(store) {
2066
+ this.engineStore = store;
2067
+ const raw = await store.collection(COLL).get("state");
2068
+ const parsed = raw === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(raw));
2069
+ if (!isObject$10(parsed) || !Array.isArray(parsed["addresses"]) || !Array.isArray(parsed["utxos"])) return;
2070
+ return {
2071
+ addresses: asArray(parsed["addresses"]).flatMap((a) => toScanAddress(a)),
2072
+ utxos: asArray(parsed["utxos"]).flatMap((u) => toScanUtxo(u)),
2073
+ scannedAt: typeof parsed["scannedAt"] === "number" ? parsed["scannedAt"] : 0
2074
+ };
2075
+ }
2076
+ async persist(state) {
2077
+ const store = this.engineStore;
2078
+ if (store !== void 0) await store.collection(COLL).put("state", new TextEncoder().encode(JSON.stringify(state)));
2079
+ }
2080
+ nextUnused(state, chain) {
2081
+ return state.addresses.find((a) => a.chain === chain && !a.used);
2082
+ }
2083
+ lock() {
2084
+ this.engineStore = void 0;
2085
+ }
2086
+ };
1337
2087
  const str$1 = (v) => typeof v === "string" ? v : void 0;
1338
2088
  const num = (v) => typeof v === "number" ? v : void 0;
2089
+ const toScanAddress = (v) => {
2090
+ if (!isObject$10(v)) return [];
2091
+ const chain = num(v["chain"]);
2092
+ const index = num(v["index"]);
2093
+ const address = str$1(v["address"]);
2094
+ const used = v["used"] === true;
2095
+ return chain !== void 0 && index !== void 0 && address !== void 0 && (chain === 0 || chain === 1) ? [{
2096
+ chain: chain === 0 ? 0 : 1,
2097
+ index,
2098
+ address,
2099
+ used
2100
+ }] : [];
2101
+ };
1339
2102
  const toScanUtxo = (v) => {
1340
2103
  if (!isObject$10(v)) return [];
1341
2104
  const txid = str$1(v["txid"]);
@@ -1452,8 +2215,7 @@ var BtcUtxoStore = class BtcUtxoStore {
1452
2215
  this.engineStore = void 0;
1453
2216
  }
1454
2217
  };
1455
- //#endregion
1456
- //#region src/accounts/errors.ts
2218
+ // src/accounts/errors.ts
1457
2219
  var AccountTreeError = class extends Error {
1458
2220
  category;
1459
2221
  constructor(category, message, cause) {
@@ -1462,8 +2224,7 @@ var AccountTreeError = class extends Error {
1462
2224
  this.category = category;
1463
2225
  }
1464
2226
  };
1465
- //#endregion
1466
- //#region src/accounts/controller.ts
2227
+ // src/accounts/controller.ts
1467
2228
  const EMPTY_TREE = {
1468
2229
  wallets: {},
1469
2230
  groups: {},
@@ -2278,8 +3039,7 @@ function persistedToState(tree) {
2278
3039
  totals: tree.totals
2279
3040
  };
2280
3041
  }
2281
- //#endregion
2282
- //#region src/chains/evm.ts
3042
+ // src/chains/evm.ts
2283
3043
  /**
2284
3044
  * EVM chain adapter: native transfers through a viem PublicClient injected by NetworkController.
2285
3045
  * Fee tiers come from eth_feeHistory (20 blocks, p10/p50/p90 → slow/normal/fast, maxFee = 2×baseFee +
@@ -2344,6 +3104,15 @@ const checkedAddress = (v) => {
2344
3104
  return v;
2345
3105
  };
2346
3106
  const hex$1 = (v) => numberToHex(v);
3107
+ const evmRpc = async (client, method, params) => client.transport.request({
3108
+ method,
3109
+ params
3110
+ });
3111
+ const evmRpcHex = async (client, method, params) => {
3112
+ const result = await evmRpc(client, method, params);
3113
+ if (typeof result !== "string") throw new TypeError(`${method} returned a malformed result`);
3114
+ return result;
3115
+ };
2347
3116
  var EvmChainAdapter = class {
2348
3117
  family = "evm";
2349
3118
  nonces = /* @__PURE__ */ new Map();
@@ -2359,20 +3128,9 @@ var EvmChainAdapter = class {
2359
3128
  this.nonces.set(key, next);
2360
3129
  return next;
2361
3130
  }
2362
- async rpc(client, method, params) {
2363
- return client.transport.request({
2364
- method,
2365
- params
2366
- });
2367
- }
2368
- async rpcHex(client, method, params) {
2369
- const result = await this.rpc(client, method, params);
2370
- if (typeof result !== "string") throw new TypeError(`${method} returned a malformed result`);
2371
- return result;
2372
- }
2373
3131
  /** The endpoint's pending transaction count for `from`. */
2374
3132
  async pendingCount(client, from) {
2375
- return Number(await this.rpcHex(client, "eth_getTransactionCount", [from, "pending"]));
3133
+ return Number(await evmRpcHex(client, "eth_getTransactionCount", [from, "pending"]));
2376
3134
  }
2377
3135
  /**
2378
3136
  * The next nonce for (network, from): the larger of the endpoint's pending count and one past the
@@ -2406,14 +3164,16 @@ var EvmChainAdapter = class {
2406
3164
  abi: ERC20_ABI,
2407
3165
  functionName: "transfer",
2408
3166
  args: [checkedAddress(intent.to), intent.value]
2409
- }) : "0x";
2410
- const estimate = BigInt(await this.rpcHex(client, "eth_estimateGas", [{
3167
+ }) : intent.kind === "call" ? intent.data : "0x";
3168
+ const estimate = BigInt(await evmRpcHex(client, "eth_estimateGas", [{
2411
3169
  from,
2412
3170
  to,
2413
3171
  value: hex$1(value),
2414
3172
  data
2415
3173
  }]));
2416
3174
  const scale = erc20 ? 11n : 12n;
3175
+ const buffered = estimate * scale / 10n + (estimate * scale % 10n === 0n ? 0n : 1n);
3176
+ const minGas = intent.kind === "call" ? intent.minGas ?? 0n : 0n;
2417
3177
  return {
2418
3178
  family: "evm",
2419
3179
  network,
@@ -2422,12 +3182,12 @@ var EvmChainAdapter = class {
2422
3182
  value,
2423
3183
  data,
2424
3184
  nonce,
2425
- gas: estimate * scale / 10n + (estimate * scale % 10n === 0n ? 0n : 1n)
3185
+ gas: buffered > minGas ? buffered : minGas
2426
3186
  };
2427
3187
  }
2428
3188
  async estimateFee(client) {
2429
3189
  try {
2430
- const history = await this.rpc(client, "eth_feeHistory", [
3190
+ const history = await evmRpc(client, "eth_feeHistory", [
2431
3191
  "0x14",
2432
3192
  "latest",
2433
3193
  [
@@ -2457,7 +3217,7 @@ var EvmChainAdapter = class {
2457
3217
  tier(2, "fast")
2458
3218
  ];
2459
3219
  } catch {
2460
- const price = BigInt(await this.rpcHex(client, "eth_gasPrice", []));
3220
+ const price = BigInt(await evmRpcHex(client, "eth_gasPrice", []));
2461
3221
  return [
2462
3222
  "slow",
2463
3223
  "normal",
@@ -2514,21 +3274,20 @@ var EvmChainAdapter = class {
2514
3274
  return `0x${[...bytes].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
2515
3275
  }
2516
3276
  async broadcast(client, raw) {
2517
- return this.rpcHex(client, "eth_sendRawTransaction", [raw]);
3277
+ return evmRpcHex(client, "eth_sendRawTransaction", [raw]);
2518
3278
  }
2519
3279
  async status(client, hash) {
2520
- const receipt = await this.rpc(client, "eth_getTransactionReceipt", [hash]);
3280
+ const receipt = await evmRpc(client, "eth_getTransactionReceipt", [hash]);
2521
3281
  if (receipt === null || typeof receipt !== "object") return "pending";
2522
3282
  if ("status" in receipt && typeof receipt.status === "string" && BigInt(receipt.status) === 0n) return "failed";
2523
3283
  try {
2524
- const finalized = await this.rpc(client, "eth_getBlockByNumber", ["finalized", false]);
3284
+ const finalized = await evmRpc(client, "eth_getBlockByNumber", ["finalized", false]);
2525
3285
  if (finalized !== null && typeof finalized === "object" && "number" in finalized && typeof finalized.number === "string" && "blockNumber" in receipt && typeof receipt.blockNumber === "string" && BigInt(receipt.blockNumber) <= BigInt(finalized.number)) return "finalized";
2526
3286
  } catch {}
2527
3287
  return "confirmed";
2528
3288
  }
2529
3289
  };
2530
- //#endregion
2531
- //#region src/assets/controller.ts
3290
+ // src/assets/controller.ts
2532
3291
  /**
2533
3292
  * EVM balances per (account address, network). Native via eth_getBalance; token balanceOf calls go
2534
3293
  * through the network's Multicall3 (one aggregate eth_call) when the registry declares it,
@@ -2660,7 +3419,7 @@ var AssetsController = class extends Controller {
2660
3419
  return;
2661
3420
  }
2662
3421
  const client = this.options.networks.client(network);
2663
- const native = BigInt(await this.rpcHex(client, "eth_getBalance", [address, "latest"])).toString();
3422
+ const native = BigInt(await evmRpcHex(client, "eth_getBalance", [address, "latest"])).toString();
2664
3423
  const custom = this.customTokens.get(network) ?? [];
2665
3424
  const tokens = await this.readTokens(client, info.multicall3, address, custom.map((t) => ({
2666
3425
  address: t.address,
@@ -2712,8 +3471,8 @@ var AssetsController = class extends Controller {
2712
3471
  const balances = multicall3 === void 0 ? await Promise.all(tokens.map(async (t) => this.balanceOf(client, t.address, owner))) : await this.balanceOfBatch(client, multicall3, owner, tokens.map((t) => t.address));
2713
3472
  return Promise.all(tokens.map(async (t, i) => ({
2714
3473
  address: t.address,
2715
- symbol: await this.callString(client, t.address, "symbol") ?? "?",
2716
- decimals: await this.callNumber(client, t.address, "decimals") ?? 18,
3474
+ symbol: await this.callToken(client, t.address, "symbol") ?? "?",
3475
+ decimals: await this.callToken(client, t.address, "decimals") ?? 18,
2717
3476
  balance: balances[i]?.toString() ?? "0",
2718
3477
  verified: t.verified
2719
3478
  })));
@@ -2732,7 +3491,7 @@ var AssetsController = class extends Controller {
2732
3491
  })
2733
3492
  }))]
2734
3493
  });
2735
- const result = await this.rpcHex(client, "eth_call", [{
3494
+ const result = await evmRpcHex(client, "eth_call", [{
2736
3495
  to: multicall3,
2737
3496
  data
2738
3497
  }, "latest"]);
@@ -2753,7 +3512,7 @@ var AssetsController = class extends Controller {
2753
3512
  functionName: "balanceOf",
2754
3513
  args: [checked(owner)]
2755
3514
  });
2756
- const result = await this.rpcHex(client, "eth_call", [{
3515
+ const result = await evmRpcHex(client, "eth_call", [{
2757
3516
  to: token,
2758
3517
  data
2759
3518
  }, "latest"]);
@@ -2764,32 +3523,12 @@ var AssetsController = class extends Controller {
2764
3523
  data: result
2765
3524
  });
2766
3525
  }
2767
- async callString(client, token, fn) {
2768
- const data = encodeFunctionData({
2769
- abi: ERC20_ABI,
2770
- functionName: fn
2771
- });
2772
- const result = await this.rpcHex(client, "eth_call", [{
2773
- to: token,
2774
- data
2775
- }, "latest"]);
2776
- if (!isHex(result)) return;
2777
- try {
2778
- return decodeFunctionResult({
2779
- abi: ERC20_ABI,
2780
- functionName: fn,
2781
- data: result
2782
- });
2783
- } catch {
2784
- return;
2785
- }
2786
- }
2787
- async callNumber(client, token, fn) {
3526
+ async callToken(client, token, fn) {
2788
3527
  const data = encodeFunctionData({
2789
3528
  abi: ERC20_ABI,
2790
3529
  functionName: fn
2791
3530
  });
2792
- const result = await this.rpcHex(client, "eth_call", [{
3531
+ const result = await evmRpcHex(client, "eth_call", [{
2793
3532
  to: token,
2794
3533
  data
2795
3534
  }, "latest"]);
@@ -2815,8 +3554,8 @@ var AssetsController = class extends Controller {
2815
3554
  return;
2816
3555
  }
2817
3556
  const client = this.options.networks.client(network);
2818
- await this.callString(client, address, "symbol");
2819
- await this.callNumber(client, address, "decimals");
3557
+ await this.callToken(client, address, "symbol");
3558
+ await this.callToken(client, address, "decimals");
2820
3559
  await this.persistToken(network, address);
2821
3560
  }
2822
3561
  async persistToken(network, address) {
@@ -2833,17 +3572,6 @@ var AssetsController = class extends Controller {
2833
3572
  await this.engineStore.collection(COLLECTION$1).put("customTokens", new TextEncoder().encode(JSON.stringify(merged)));
2834
3573
  }
2835
3574
  }
2836
- async rpc(client, method, params) {
2837
- return client.transport.request({
2838
- method,
2839
- params
2840
- });
2841
- }
2842
- async rpcHex(client, method, params) {
2843
- const result = await this.rpc(client, method, params);
2844
- if (typeof result !== "string") throw new TypeError(`${method} returned a malformed result`);
2845
- return result;
2846
- }
2847
3575
  /** BTC balance = confirmed spendable + own unconfirmed change; frozen 546/330 shown separately. */
2848
3576
  async refreshBitcoin(xpub, network) {
2849
3577
  const es = this.options.networks.esplora(network);
@@ -2878,8 +3606,7 @@ var AssetsController = class extends Controller {
2878
3606
  }));
2879
3607
  }
2880
3608
  };
2881
- //#endregion
2882
- //#region src/backup/errors.ts
3609
+ // src/backup/errors.ts
2883
3610
  var BackupControllerError = class extends Error {
2884
3611
  category;
2885
3612
  constructor(category, message) {
@@ -2888,8 +3615,7 @@ var BackupControllerError = class extends Error {
2888
3615
  this.category = category;
2889
3616
  }
2890
3617
  };
2891
- //#endregion
2892
- //#region src/backup/controller.ts
3618
+ // src/backup/controller.ts
2893
3619
  /**
2894
3620
  * The backup reconcile loop (design/backup.mdx "同步"): keeps the cloud file
2895
3621
  * `qntx-wallet-backup-<id>.json` in step with the vault. Single-flight — a trigger during a run
@@ -2928,17 +3654,15 @@ function parseRecord(bytes) {
2928
3654
  const { digest } = o;
2929
3655
  const { uploadedAt } = o;
2930
3656
  const { walletIds } = o;
2931
- const { nostrKeyIds } = o;
2932
3657
  if (typeof id !== "string" || !/^[0-9a-f]{32}$/.test(id)) return;
2933
3658
  if (digest !== void 0 && (typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest))) return;
2934
3659
  if (uploadedAt !== void 0 && (typeof uploadedAt !== "number" || !Number.isSafeInteger(uploadedAt))) return;
2935
- if (!isStringArray(walletIds) || !isStringArray(nostrKeyIds)) return;
3660
+ if (!isStringArray(walletIds)) return;
2936
3661
  return {
2937
3662
  id,
2938
3663
  digest,
2939
3664
  uploadedAt,
2940
- walletIds,
2941
- nostrKeyIds
3665
+ walletIds
2942
3666
  };
2943
3667
  } catch {
2944
3668
  return;
@@ -3055,8 +3779,7 @@ var BackupController = class extends Controller {
3055
3779
  id,
3056
3780
  digest: digestOf(text),
3057
3781
  uploadedAt: previous?.uploadedAt,
3058
- walletIds: [],
3059
- nostrKeyIds: []
3782
+ walletIds: []
3060
3783
  });
3061
3784
  }
3062
3785
  try {
@@ -3086,20 +3809,10 @@ var BackupController = class extends Controller {
3086
3809
  }
3087
3810
  unprotected(snapshot, record) {
3088
3811
  const covered = snapshot.backup !== null && record !== void 0 && record.id === snapshot.backup.id && record.digest !== void 0;
3089
- const wallets = snapshot.wallets.filter((w) => w.backup !== "mnemonic");
3090
- const keys = snapshot.nostr.keys.filter((k) => k.ref.kind === "standalone" && k.backedUp === false);
3091
- const walletIds = wallets.map((w) => w.walletId).filter((id) => !covered || !record.walletIds.includes(id));
3092
- const nostrKeyIds = keys.map((k) => k.publicKey).filter((id) => !covered || !record.nostrKeyIds.includes(id));
3093
- return {
3094
- walletIds: sorted(walletIds),
3095
- nostrKeyIds: sorted(nostrKeyIds)
3096
- };
3812
+ return { walletIds: sorted(snapshot.wallets.filter((w) => w.backup !== "mnemonic").map((w) => w.walletId).filter((id) => !covered || !record.walletIds.includes(id))) };
3097
3813
  }
3098
3814
  coverage(snapshot) {
3099
- return {
3100
- walletIds: sorted(snapshot.wallets.map((w) => w.walletId)),
3101
- nostrKeyIds: sorted(snapshot.nostr.keys.filter((k) => k.ref.kind === "standalone").map((k) => k.publicKey))
3102
- };
3815
+ return { walletIds: sorted(snapshot.wallets.map((w) => w.walletId)) };
3103
3816
  }
3104
3817
  setStatus(generation, status, error, record) {
3105
3818
  if (generation !== this.generation) return;
@@ -3151,8 +3864,7 @@ var BackupController = class extends Controller {
3151
3864
  id: adoption.id,
3152
3865
  digest: adoption.digest,
3153
3866
  uploadedAt: void 0,
3154
- walletIds: [],
3155
- nostrKeyIds: []
3867
+ walletIds: []
3156
3868
  };
3157
3869
  if (generation !== this.generation) return;
3158
3870
  await this.writeRecord(record);
@@ -3175,7 +3887,7 @@ var BackupController = class extends Controller {
3175
3887
  }
3176
3888
  const baseline = record !== void 0 && record.id === backup.id ? record.digest : void 0;
3177
3889
  const cov = this.coverage(snapshot);
3178
- if (baseline !== void 0 && record !== void 0 && sameSet(cov.walletIds, record.walletIds) && sameSet(cov.nostrKeyIds, record.nostrKeyIds)) {
3890
+ if (baseline !== void 0 && record !== void 0 && sameSet(cov.walletIds, record.walletIds)) {
3179
3891
  this.setStatus(generation, "synced", void 0, record);
3180
3892
  return;
3181
3893
  }
@@ -3200,8 +3912,7 @@ var BackupController = class extends Controller {
3200
3912
  id: backup.id,
3201
3913
  digest,
3202
3914
  uploadedAt: this.now(),
3203
- walletIds: cov.walletIds,
3204
- nostrKeyIds: cov.nostrKeyIds
3915
+ walletIds: cov.walletIds
3205
3916
  };
3206
3917
  if (generation !== this.generation) return;
3207
3918
  await this.writeRecord(next);
@@ -3224,10 +3935,7 @@ function defaultState() {
3224
3935
  status: "locked",
3225
3936
  backup: null,
3226
3937
  uploadedAt: void 0,
3227
- unprotected: {
3228
- walletIds: [],
3229
- nostrKeyIds: []
3230
- },
3938
+ unprotected: { walletIds: [] },
3231
3939
  error: void 0
3232
3940
  };
3233
3941
  }
@@ -3324,8 +4032,7 @@ function decodeMetaDocBytes(bytes) {
3324
4032
  return;
3325
4033
  }
3326
4034
  }
3327
- //#endregion
3328
- //#region src/meta/errors.ts
4035
+ // src/meta/errors.ts
3329
4036
  var MetaSyncError = class extends Error {
3330
4037
  category;
3331
4038
  constructor(category, message, cause) {
@@ -3334,15 +4041,15 @@ var MetaSyncError = class extends Error {
3334
4041
  this.category = category;
3335
4042
  }
3336
4043
  };
3337
- //#endregion
3338
- //#region src/meta/controller.ts
4044
+ // src/meta/controller.ts
3339
4045
  const META_SYNC_KIND = 30078;
3340
4046
  const PUBLISH_DEBOUNCE_MS = 2e3;
3341
4047
  /**
3342
4048
  * Wallet-metadata sync over kind-30078 addressable events: one NIP-44-encrypted document per
3343
- * wallet, authored by its group-0 derived key. Pull always precedes publish in an unlock session —
3344
- * replaceable events would otherwise let a stale local tree overwrite a newer remote document.
3345
- * Debounced publishes are per wallet; a generation counter cancels in-flight write-backs on lock.
4049
+ * wallet, authored by its dedicated sync key (WD-070). Pull always precedes publish in an unlock
4050
+ * session — replaceable events would otherwise let a stale local tree overwrite a newer remote
4051
+ * document. Debounced publishes are per wallet; a generation counter cancels in-flight write-backs
4052
+ * on lock.
3346
4053
  */
3347
4054
  var MetaSyncController = class extends Controller {
3348
4055
  options;
@@ -3414,19 +4121,6 @@ var MetaSyncController = class extends Controller {
3414
4121
  const snap = this.options.wallet.getSnapshot();
3415
4122
  await Promise.all(snap.wallets.map(async (w) => this.pullOne(w.walletId, gen)));
3416
4123
  }
3417
- async pubkeyFor(signer) {
3418
- return signer.getPublicKey();
3419
- }
3420
- signerFor(walletId) {
3421
- const snap = this.options.wallet.getSnapshot();
3422
- const ref = {
3423
- kind: "derived",
3424
- walletId,
3425
- groupIndex: 0
3426
- };
3427
- if (!snap.nostr.keys.some((k) => k.ref.kind === "derived" && k.ref.walletId === walletId && k.ref.groupIndex === 0)) return;
3428
- return this.options.nostr.signer(ref);
3429
- }
3430
4124
  setStatus(walletId, status) {
3431
4125
  this.update((s) => ({
3432
4126
  ...s,
@@ -3439,15 +4133,11 @@ var MetaSyncController = class extends Controller {
3439
4133
  async pullOne(walletId, gen) {
3440
4134
  const config = this.options.metaSync;
3441
4135
  if (config === void 0 || this.inflight.has(walletId)) return;
3442
- const signer = this.signerFor(walletId);
3443
- if (signer === void 0) {
3444
- if (gen === this.generation) this.setStatus(walletId, { status: "unavailable" });
3445
- return;
3446
- }
4136
+ const signer = this.options.nostr.syncSigner(walletId);
3447
4137
  this.inflight.add(walletId);
3448
4138
  this.setStatus(walletId, { status: "syncing" });
3449
4139
  try {
3450
- const pubkey = await this.pubkeyFor(signer);
4140
+ const pubkey = await signer.getPublicKey();
3451
4141
  const events = await config.relays.fetch(this.filterFor(pubkey, config.tag));
3452
4142
  let remote;
3453
4143
  let remoteSkipped = 0;
@@ -3467,8 +4157,7 @@ var MetaSyncController = class extends Controller {
3467
4157
  this.setStatus(walletId, { status: "synced" });
3468
4158
  if (hasEdits) await this.publishNow(walletId, gen);
3469
4159
  } else {
3470
- const decrypt = signer.nip44Decrypt;
3471
- const plaintext = decrypt === void 0 ? void 0 : await decrypt.call(signer, pubkey, remote.content).catch(() => void 0);
4160
+ const plaintext = await signer.decrypt(remote.content).catch(() => void 0);
3472
4161
  const doc = plaintext === void 0 ? void 0 : decodeMetaDocBytes(new TextEncoder().encode(plaintext));
3473
4162
  if (doc === void 0) {
3474
4163
  if (gen !== this.generation) return;
@@ -3551,18 +4240,12 @@ var MetaSyncController = class extends Controller {
3551
4240
  async publishNow(walletId, gen) {
3552
4241
  const config = this.options.metaSync;
3553
4242
  if (config === void 0 || !this.pulled.has(walletId)) return;
3554
- const signer = this.signerFor(walletId);
3555
- if (signer === void 0) {
3556
- this.setStatus(walletId, { status: "unavailable" });
3557
- return;
3558
- }
4243
+ const signer = this.options.nostr.syncSigner(walletId);
3559
4244
  try {
3560
4245
  const bytes = encodeMetaDoc(this.exportDoc(walletId));
3561
4246
  if (bytes === void 0) throw new MetaSyncError("internal", "meta doc exceeds 64 KB");
3562
- const encrypt = signer.nip44Encrypt;
3563
- if (encrypt === void 0) throw new MetaSyncError("internal", "signer lacks nip44 encryption");
3564
- const pubkey = await this.pubkeyFor(signer);
3565
- const content = await encrypt.call(signer, pubkey, new TextDecoder().decode(bytes));
4247
+ const pubkey = await signer.getPublicKey();
4248
+ const content = await signer.encrypt(new TextDecoder().decode(bytes));
3566
4249
  const lastRemote = this.lastRemoteCreatedAt.get(walletId) ?? 0;
3567
4250
  const created_at = Math.max(Math.floor(this.options.now() / 1e3), lastRemote + 1);
3568
4251
  const event = await signer.signEvent({
@@ -3597,8 +4280,7 @@ function isUnsupportedDoc(plaintext) {
3597
4280
  return false;
3598
4281
  }
3599
4282
  }
3600
- //#endregion
3601
- //#region src/chains/bitcoin.ts
4283
+ // src/chains/bitcoin.ts
3602
4284
  const DUST = {
3603
4285
  p2wpkh: 294,
3604
4286
  p2tr: 330,
@@ -3733,8 +4415,8 @@ var BitcoinChainAdapter = class {
3733
4415
  entry.nonWitnessUtxo = raw;
3734
4416
  }
3735
4417
  if (template === "btc.bip86") {
3736
- entry.tapInternalKey = pub.subarray(1);
3737
- entry.tapBip32Derivation = [[pub.subarray(1), {
4418
+ entry.tapInternalKey = pub;
4419
+ entry.tapBip32Derivation = [[pub, {
3738
4420
  hashes: [],
3739
4421
  der: {
3740
4422
  fingerprint,
@@ -3790,8 +4472,7 @@ var BitcoinChainAdapter = class {
3790
4472
  };
3791
4473
  }
3792
4474
  };
3793
- //#endregion
3794
- //#region src/networks/controller.ts
4475
+ // src/networks/controller.ts
3795
4476
  /**
3796
4477
  * EVM network controller: the built-in registry plus host-provided and user-added RPC endpoints.
3797
4478
  * Each network gets a lazily-created viem PublicClient on a `fallback` transport — endpoints are
@@ -4064,8 +4745,7 @@ var NetworkController = class extends Controller {
4064
4745
  if (store !== void 0) await store.collection("networks").put("customRpc", new TextEncoder().encode(JSON.stringify(customRpc)));
4065
4746
  }
4066
4747
  };
4067
- //#endregion
4068
- //#region src/payments/parse.ts
4748
+ // src/payments/parse.ts
4069
4749
  /** Invoices with less than this much life left are treated as already expired. */
4070
4750
  const BOLT11_EXPIRY_GRACE_MS = 6e4;
4071
4751
  const invalid = (reason) => ({
@@ -4335,8 +5015,7 @@ function parsePayment(input, options) {
4335
5015
  return invalid("malformed");
4336
5016
  }
4337
5017
  }
4338
- //#endregion
4339
- //#region src/payments/resolve.ts
5018
+ // src/payments/resolve.ts
4340
5019
  /**
4341
5020
  * `parsePayment` plus LNURL dereferencing: an `lnurl` result is fetched and turned into a concrete
4342
5021
  * `lnurl-pay` / `lnurl-withdraw` request (`tag=login` endpoints report as unsupported). Network
@@ -4371,8 +5050,7 @@ async function resolvePayment(input, options) {
4371
5050
  format: "lnurl-auth"
4372
5051
  };
4373
5052
  }
4374
- //#endregion
4375
- //#region src/spark/notes.ts
5053
+ // src/spark/notes.ts
4376
5054
  const HEX64 = /^[0-9a-f]{64}$/;
4377
5055
  const isObject$4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4378
5056
  const NOTE_FIELDS = /* @__PURE__ */ new Set([
@@ -4403,8 +5081,7 @@ function parsePaymentNote(value) {
4403
5081
  function encodePaymentNote(note) {
4404
5082
  return new TextEncoder().encode(JSON.stringify(note));
4405
5083
  }
4406
- //#endregion
4407
- //#region src/spark/exit-chain-codec.ts
5084
+ // src/spark/exit-chain-codec.ts
4408
5085
  /**
4409
5086
  * SparkExitChain persistence codec: JSON with Uint8Arrays tagged `{ $b64: "…" }` — small and
4410
5087
  * explicit rather than schema-bound, so upstream field additions survive a round-trip.
@@ -4431,8 +5108,7 @@ function encodeExitChain(chain) {
4431
5108
  function decodeExitChain(bytes) {
4432
5109
  return decodeValue(JSON.parse(new TextDecoder().decode(bytes)));
4433
5110
  }
4434
- //#endregion
4435
- //#region src/spark/store.ts
5111
+ // src/spark/store.ts
4436
5112
  const ALL = "__all__";
4437
5113
  /**
4438
5114
  * SparkStore over one EngineCollection: chains keyed by leafId, plus an index record listing them
@@ -4476,8 +5152,7 @@ function sparkStore(col) {
4476
5152
  }
4477
5153
  };
4478
5154
  }
4479
- //#endregion
4480
- //#region src/spark/controller.ts
5155
+ // src/spark/controller.ts
4481
5156
  const key = (ref) => `${ref.walletId}:${ref.groupIndex}:${ref.network}`;
4482
5157
  /** TransferId: the client and SO expect a UUIDv7 — mint it from the injected clock+random. */
4483
5158
  const transferId = (random, now) => uuidv7(random, now);
@@ -4894,6 +5569,14 @@ var SparkController = class extends Controller {
4894
5569
  ...params.memo === void 0 ? {} : { memo: params.memo }
4895
5570
  }));
4896
5571
  }
5572
+ /** A fresh single-use L1 deposit address (with the SO's proof of possession). */
5573
+ async newDepositAddress(ref) {
5574
+ return this.guarded(async () => this.clientFor(ref).deposits.newAddress());
5575
+ }
5576
+ /** Claims a confirmed L1 deposit paying one of the account's unused deposit addresses. */
5577
+ async claimDeposit(ref, transaction) {
5578
+ await this.guarded(async () => this.clientFor(ref).deposits.claim({ transaction }));
5579
+ }
4897
5580
  async receiveAddress(ref) {
4898
5581
  const state = this.getState().accounts[key(ref)];
4899
5582
  await Promise.resolve();
@@ -4941,10 +5624,77 @@ var SparkController = class extends Controller {
4941
5624
  });
4942
5625
  }
4943
5626
  };
4944
- //#endregion
4945
- //#region src/transactions/bitcoin.ts
5627
+ // src/spark/deposit-claim.ts
5628
+ /**
5629
+ * Claims Spark deposits: once a transaction tagged `sparkDeposit` is confirmed, its raw bytes are
5630
+ * read from Esplora and handed to the Spark account's `deposits.claim`. Claiming too early (the SO
5631
+ * wants enough confirmations) just fails and is retried with backoff; locking stops everything.
5632
+ * Works only from the transaction state, so a restart resumes whatever is confirmed and unclaimed.
5633
+ */
5634
+ const pending = (tx) => tx.sparkDeposit !== void 0 && tx.state === "confirmed" && tx.sparkDeposit.claimed !== true;
5635
+ var DepositClaimer = class {
5636
+ options;
5637
+ active = /* @__PURE__ */ new Map();
5638
+ unsubscribe;
5639
+ constructor(options) {
5640
+ this.options = options;
5641
+ }
5642
+ /** Starts watching; every confirmed, unclaimed deposit already in the state is picked up. */
5643
+ start() {
5644
+ this.stop();
5645
+ this.unsubscribe = this.options.transactions.store.subscribe(() => {
5646
+ this.scan();
5647
+ });
5648
+ this.scan();
5649
+ }
5650
+ stop() {
5651
+ this.unsubscribe?.();
5652
+ this.unsubscribe = void 0;
5653
+ for (const abort of this.active.values()) abort.abort();
5654
+ this.active.clear();
5655
+ }
5656
+ scan() {
5657
+ for (const tx of Object.values(this.options.transactions.getState().txs)) if (pending(tx) && !this.active.has(tx.id)) {
5658
+ const abort = new AbortController();
5659
+ this.active.set(tx.id, abort);
5660
+ this.claim(tx.id, abort.signal);
5661
+ }
5662
+ }
5663
+ async claim(id, signal) {
5664
+ for (let failures = 0; !signal.aborted; failures += 1) {
5665
+ const tx = this.options.transactions.getState().txs[id];
5666
+ if (tx === void 0 || !pending(tx) || tx.sparkDeposit === void 0) break;
5667
+ const { walletId, groupIndex, network: sparkNetwork } = tx.sparkDeposit;
5668
+ try {
5669
+ if (tx.hash === void 0) throw new Error("the deposit has no transaction id");
5670
+ const raw = await this.options.networks.esplora(tx.network).get(`/tx/${tx.hash}/hex`);
5671
+ if (typeof raw !== "string") throw new TypeError("esplora returned a malformed transaction");
5672
+ await this.options.spark.claimDeposit({
5673
+ walletId,
5674
+ groupIndex,
5675
+ network: sparkNetwork
5676
+ }, hex.decode(raw.trim()));
5677
+ if (signal.aborted) break;
5678
+ await this.options.transactions.markDepositClaimed(id);
5679
+ break;
5680
+ } catch (error) {
5681
+ this.options.logger.warn(`spark deposit ${id} not claimed yet`, error);
5682
+ await eventsBackoff(failures, signal);
5683
+ }
5684
+ }
5685
+ if (this.active.get(id)?.signal === signal) this.active.delete(id);
5686
+ }
5687
+ };
5688
+ // src/transactions/bitcoin.ts
4946
5689
  /** Bitcoin route: scan → coin-select → PSBT → sign → broadcast, plus esplora status poll. */
4947
5690
  const isObject$3 = (v) => typeof v === "object" && v !== null;
5691
+ const broadcastPsbt = async (es, result) => {
5692
+ if (result.kind !== "bitcoin.psbt" || !(result.signedPsbt instanceof Uint8Array)) throw new Error("unexpected bitcoin sign result");
5693
+ const signed = Transaction.fromPSBT(result.signedPsbt);
5694
+ signed.finalize();
5695
+ const raw = signed.extract();
5696
+ return String(await es.post("/tx", hex.encode(raw)));
5697
+ };
4948
5698
  const fetchRawTxes = async (es, utxos) => {
4949
5699
  const out = /* @__PURE__ */ new Map();
4950
5700
  await Promise.all(utxos.map(async (u) => {
@@ -4968,6 +5718,23 @@ const poll = async (ctx, tx) => {
4968
5718
  if (!(isObject$3(status) && status["confirmed"] === true)) return;
4969
5719
  return "confirmed";
4970
5720
  };
5721
+ const nextReceiveAddress = async (ctx, walletId, groupIndex, network) => {
5722
+ const account = await (ctx.options.chains ?? chains(ctx.options.wallet)).account({
5723
+ walletId,
5724
+ groupIndex,
5725
+ template: "btc.bip84",
5726
+ network
5727
+ });
5728
+ if (!("xpub" in account) || account.xpub === void 0) throw new Error("bitcoin account carries no xpub");
5729
+ const receive = await scanChain(ctx.options.networks.esplora(network), account.xpub, "btc.bip84", network, bitcoinNetwork(network).params, 0);
5730
+ const next = new BitcoinScanStore().nextUnused({
5731
+ addresses: receive,
5732
+ utxos: [],
5733
+ scannedAt: ctx.options.now()
5734
+ }, 0);
5735
+ if (next === void 0) throw new Error("no unused receive address");
5736
+ return next.address;
5737
+ };
4971
5738
  /** Per-controller route — the UTXO store and the build stash are engine state. */
4972
5739
  const createBitcoinRoute = () => {
4973
5740
  const bitcoin = new BitcoinChainAdapter();
@@ -4989,7 +5756,7 @@ const createBitcoinRoute = () => {
4989
5756
  const releaseReservation = (requestId) => {
4990
5757
  for (const [key, holder] of reserved) if (holder === requestId) reserved.delete(key);
4991
5758
  };
4992
- const prepare = async (ctx, params, requestId) => {
5759
+ const prepare = async (ctx, params, requestId, sparkDeposit) => {
4993
5760
  const { account, network, to, amount, fee } = params;
4994
5761
  if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
4995
5762
  const es = ctx.options.networks.esplora(network);
@@ -5147,6 +5914,7 @@ const createBitcoinRoute = () => {
5147
5914
  intent,
5148
5915
  nonce: 0,
5149
5916
  btc: record,
5917
+ ...sparkDeposit === void 0 ? {} : { sparkDeposit },
5150
5918
  submittedAt: ctx.options.now(),
5151
5919
  error: void 0
5152
5920
  };
@@ -5157,26 +5925,12 @@ const createBitcoinRoute = () => {
5157
5925
  verify
5158
5926
  });
5159
5927
  tx = await ctx.transition(tx, { state: "signed" });
5160
- if (result.kind !== "bitcoin.psbt" || !(result.signedPsbt instanceof Uint8Array)) throw new Error("unexpected bitcoin sign result");
5161
- const signed = Transaction.fromPSBT(result.signedPsbt);
5162
- signed.finalize();
5163
- const raw = signed.extract();
5164
- const hash = String(await es.post("/tx", hex.encode(raw)));
5928
+ const hash = await broadcastPsbt(es, result);
5165
5929
  bitcoinUtxos.lock(selectedUtxos, tx.id);
5166
5930
  releaseReservation(requestId);
5167
- tx = await ctx.transition(tx, {
5168
- state: "submitted",
5169
- hash,
5170
- submittedAt: ctx.options.now()
5171
- });
5172
- ctx.track(tx.id, hash);
5173
- return tx;
5931
+ return await submitTx(ctx, tx, hash);
5174
5932
  } catch (error) {
5175
- const failed = tx.state === "signed" ? "failed" : "rejected";
5176
- await ctx.transition(tx, {
5177
- state: failed,
5178
- error: errorMessage$1(error)
5179
- });
5933
+ await failTx(ctx, tx, error);
5180
5934
  throw error;
5181
5935
  }
5182
5936
  };
@@ -5235,11 +5989,7 @@ const createBitcoinRoute = () => {
5235
5989
  verify
5236
5990
  });
5237
5991
  tx = await ctx.transition(tx, { state: "signed" });
5238
- if (result.kind !== "bitcoin.psbt" || !(result.signedPsbt instanceof Uint8Array)) throw new Error("unexpected bitcoin sign result");
5239
- const signed = Transaction.fromPSBT(result.signedPsbt);
5240
- signed.finalize();
5241
- const raw = signed.extract();
5242
- const hash = String(await es.post("/tx", hex.encode(raw)));
5992
+ const hash = await broadcastPsbt(es, result);
5243
5993
  if (link.replaces !== void 0) bitcoinUtxos.endTx(old.id, "dropped");
5244
5994
  bitcoinUtxos.lock(record.inputs.map((i) => ({
5245
5995
  ...i,
@@ -5247,19 +5997,9 @@ const createBitcoinRoute = () => {
5247
5997
  height: 0
5248
5998
  })), tx.id);
5249
5999
  releaseReservation(requestId);
5250
- tx = await ctx.transition(tx, {
5251
- state: "submitted",
5252
- hash,
5253
- submittedAt: ctx.options.now()
5254
- });
5255
- ctx.track(tx.id, hash);
5256
- return tx;
6000
+ return await submitTx(ctx, tx, hash);
5257
6001
  } catch (error) {
5258
- const failed = tx.state === "signed" ? "failed" : "rejected";
5259
- await ctx.transition(tx, {
5260
- state: failed,
5261
- error: errorMessage$1(error)
5262
- });
6002
+ await failTx(ctx, tx, error);
5263
6003
  throw error;
5264
6004
  }
5265
6005
  };
@@ -5471,6 +6211,7 @@ const createBitcoinRoute = () => {
5471
6211
  };
5472
6212
  return {
5473
6213
  prepare,
6214
+ nextReceiveAddress,
5474
6215
  poll: pollAndRelease,
5475
6216
  prepareReplace,
5476
6217
  prepareCpfp,
@@ -5481,17 +6222,50 @@ const createBitcoinRoute = () => {
5481
6222
  }
5482
6223
  };
5483
6224
  };
5484
- //#endregion
5485
- //#region src/transactions/evm.ts
6225
+ // src/transactions/evm.ts
5486
6226
  /** EVM route: native/ERC-20 send build → fee → decode → sign → broadcast, plus status poll. */
5487
6227
  const bump = (v) => (v * 11n + 9n) / 10n;
6228
+ const signSubmit = async (ctx, tx, apis, request, verify, broadcast) => {
6229
+ let signed;
6230
+ let tracked = tx;
6231
+ try {
6232
+ const result = await apis.sign({
6233
+ request,
6234
+ verify
6235
+ });
6236
+ tracked = await ctx.transition(tracked, { state: "signed" });
6237
+ signed = result;
6238
+ } catch (error) {
6239
+ await ctx.transition(tracked, {
6240
+ state: "rejected",
6241
+ error: errorMessage$1(error)
6242
+ });
6243
+ throw error;
6244
+ }
6245
+ try {
6246
+ const hash = await broadcast(signed);
6247
+ tracked = await ctx.transition(tracked, {
6248
+ state: "submitted",
6249
+ hash,
6250
+ submittedAt: ctx.options.now()
6251
+ });
6252
+ ctx.track(tracked.id, hash);
6253
+ return tracked;
6254
+ } catch (error) {
6255
+ await ctx.transition(tracked, {
6256
+ state: "failed",
6257
+ error: errorMessage$1(error)
6258
+ });
6259
+ throw error;
6260
+ }
6261
+ };
5488
6262
  /** Per-controller route — the adapter's nonce queue and the replace stash are engine state. */
5489
6263
  const createEvmRoute = () => {
5490
6264
  const evm = new EvmChainAdapter();
5491
6265
  /** In-memory build context keyed by tx id — the inputs a replace rebuilds from. */
5492
6266
  const pending = /* @__PURE__ */ new Map();
5493
- const prepare = async (ctx, params) => {
5494
- const { account, network, to, amount, fee } = params;
6267
+ const prepareSpec = async (ctx, spec) => {
6268
+ const { account, network, fee } = spec;
5495
6269
  const { networks } = ctx.options;
5496
6270
  if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
5497
6271
  const info = networks.info(network);
@@ -5514,16 +6288,7 @@ const createEvmRoute = () => {
5514
6288
  } catch {
5515
6289
  flags.push("reads-unverified");
5516
6290
  }
5517
- const unsigned = await evm.build(client, network, from, params.token === void 0 ? {
5518
- kind: "native",
5519
- to,
5520
- value: amount
5521
- } : {
5522
- kind: "erc20",
5523
- token: params.token,
5524
- to,
5525
- value: amount
5526
- }, nonce);
6291
+ const unsigned = await evm.build(client, network, from, spec.intent, nonce);
5527
6292
  const quotes = await evm.estimateFee(client);
5528
6293
  let quote = quotes.find((q) => q.tier === fee) ?? quotes[1];
5529
6294
  if (quote === void 0) throw new Error("no fee quote");
@@ -5539,7 +6304,7 @@ const createEvmRoute = () => {
5539
6304
  flags.push("reads-unverified");
5540
6305
  }
5541
6306
  const feed = evm.applyFee(unsigned, quote);
5542
- if (params.token === void 0 && feeReachesAmount(feed.gas * (feed.maxFeePerGas ?? feed.gasPrice ?? 0n), amount)) flags.push("fee-exceeds-amount");
6307
+ if (spec.nativeValue !== void 0 && feeReachesAmount(feed.gas * (feed.maxFeePerGas ?? feed.gasPrice ?? 0n), spec.nativeValue)) flags.push("fee-exceeds-amount");
5543
6308
  const request = evm.toSignRequest(feed, account, info.chainId);
5544
6309
  const intent = decodeEvm(request);
5545
6310
  const txId = newId(ctx.options.random);
@@ -5564,39 +6329,12 @@ const createEvmRoute = () => {
5564
6329
  error: void 0
5565
6330
  };
5566
6331
  tx = await ctx.transition(tx, {});
5567
- let signed;
5568
- try {
5569
- const result = await apis.sign({
5570
- request,
5571
- verify
5572
- });
5573
- tx = await ctx.transition(tx, { state: "signed" });
5574
- signed = result;
5575
- } catch (error) {
5576
- await ctx.transition(tx, {
5577
- state: "rejected",
5578
- error: errorMessage$1(error)
5579
- });
5580
- throw error;
5581
- }
5582
- try {
6332
+ return signSubmit(ctx, tx, apis, request, verify, async (signed) => {
5583
6333
  const raw = evm.finalize(feed, signed);
5584
6334
  const hash = await evm.broadcast(client, raw);
5585
6335
  evm.commitNonce(network, from, unsigned.nonce);
5586
- tx = await ctx.transition(tx, {
5587
- state: "submitted",
5588
- hash,
5589
- submittedAt: ctx.options.now()
5590
- });
5591
- ctx.track(tx.id, hash);
5592
- return tx;
5593
- } catch (error) {
5594
- await ctx.transition(tx, {
5595
- state: "failed",
5596
- error: errorMessage$1(error)
5597
- });
5598
- throw error;
5599
- }
6336
+ return hash;
6337
+ });
5600
6338
  };
5601
6339
  return {
5602
6340
  txId,
@@ -5609,6 +6347,44 @@ const createEvmRoute = () => {
5609
6347
  release: () => void 0
5610
6348
  };
5611
6349
  };
6350
+ const prepare = async (ctx, params) => prepareSpec(ctx, {
6351
+ account: params.account,
6352
+ network: params.network,
6353
+ fee: params.fee,
6354
+ intent: params.token === void 0 ? {
6355
+ kind: "native",
6356
+ to: params.to,
6357
+ value: params.amount
6358
+ } : {
6359
+ kind: "erc20",
6360
+ token: params.token,
6361
+ to: params.to,
6362
+ value: params.amount
6363
+ },
6364
+ nativeValue: params.token === void 0 ? params.amount : void 0
6365
+ });
6366
+ /**
6367
+ * A dApp transaction, already normalized: only `to`, `value` and `data` are kept; nonce and fees
6368
+ * are read again and the dApp's `gas` only raises the floor under our own estimate.
6369
+ */
6370
+ const prepareCall = async (ctx, request) => {
6371
+ const { tx } = request;
6372
+ if (tx.to === void 0) throw new Error("contract creation is not supported");
6373
+ const value = BigInt(tx.value);
6374
+ return prepareSpec(ctx, {
6375
+ account: request.account,
6376
+ network: request.network,
6377
+ fee: "normal",
6378
+ intent: {
6379
+ kind: "call",
6380
+ to: tx.to,
6381
+ value,
6382
+ data: tx.data,
6383
+ minGas: BigInt(tx.gas)
6384
+ },
6385
+ nativeValue: value > 0n ? value : void 0
6386
+ });
6387
+ };
5612
6388
  /**
5613
6389
  * EVM speed-up/cancel — replaces a pending tx at the same nonce with a bumped fee (cancel = a
5614
6390
  * 0-value transfer to self). The fee is cross-checked and merged first, then the replacement
@@ -5685,38 +6461,7 @@ const createEvmRoute = () => {
5685
6461
  error: void 0
5686
6462
  };
5687
6463
  tx = await ctx.transition(tx, {});
5688
- let signed;
5689
- try {
5690
- const result = await apis.sign({
5691
- request,
5692
- verify
5693
- });
5694
- tx = await ctx.transition(tx, { state: "signed" });
5695
- signed = result;
5696
- } catch (error) {
5697
- await ctx.transition(tx, {
5698
- state: "rejected",
5699
- error: errorMessage$1(error)
5700
- });
5701
- throw error;
5702
- }
5703
- try {
5704
- const raw = evm.finalize(unsigned, signed);
5705
- const hash = await evm.broadcast(client, raw);
5706
- tx = await ctx.transition(tx, {
5707
- state: "submitted",
5708
- hash,
5709
- submittedAt: ctx.options.now()
5710
- });
5711
- ctx.track(tx.id, hash);
5712
- return tx;
5713
- } catch (error) {
5714
- await ctx.transition(tx, {
5715
- state: "failed",
5716
- error: errorMessage$1(error)
5717
- });
5718
- throw error;
5719
- }
6464
+ return signSubmit(ctx, tx, apis, request, verify, async (signed) => evm.broadcast(client, evm.finalize(unsigned, signed)));
5720
6465
  };
5721
6466
  return {
5722
6467
  txId,
@@ -5740,6 +6485,7 @@ const createEvmRoute = () => {
5740
6485
  };
5741
6486
  return {
5742
6487
  prepare,
6488
+ prepareCall,
5743
6489
  poll,
5744
6490
  prepareReplace
5745
6491
  };
@@ -5928,8 +6674,7 @@ var SolanaChainAdapter = class {
5928
6674
  return BigInt(str(result, "0"));
5929
6675
  }
5930
6676
  };
5931
- //#endregion
5932
- //#region src/transactions/solana.ts
6677
+ // src/transactions/solana.ts
5933
6678
  /** Solana route: SOL/SPL send → sign → broadcast, plus status poll with rebroadcast/drop. */
5934
6679
  /** The network's base signature fee, lamports per signature. */
5935
6680
  const BASE_FEE_LAMPORTS = 5000n;
@@ -6038,19 +6783,9 @@ const prepareSolana = async (ctx, params) => {
6038
6783
  const wire = solana.finalize(messageBytes, [result.signature]);
6039
6784
  const hash = await solana.broadcast(rpc, wire);
6040
6785
  wires.set(id, wire);
6041
- tx = await ctx.transition(tx, {
6042
- state: "submitted",
6043
- hash,
6044
- submittedAt: ctx.options.now()
6045
- });
6046
- ctx.track(tx.id, hash);
6047
- return tx;
6786
+ return await submitTx(ctx, tx, hash);
6048
6787
  } catch (error) {
6049
- const failed = tx.state === "signed" ? "failed" : "rejected";
6050
- await ctx.transition(tx, {
6051
- state: failed,
6052
- error: errorMessage$1(error)
6053
- });
6788
+ await failTx(ctx, tx, error);
6054
6789
  throw error;
6055
6790
  }
6056
6791
  };
@@ -6123,13 +6858,7 @@ const token2022Intent = (intent, ext, mint, amount) => {
6123
6858
  warnings
6124
6859
  };
6125
6860
  };
6126
- //#endregion
6127
- //#region src/transactions/controller.ts
6128
- /**
6129
- * Transaction controller: orchestrates lifecycle, persistence and polling; each chain family routes
6130
- * through its own module (`evm.ts`, `solana.ts`, `bitcoin.ts`). A rejected signature (wrong
6131
- * password, cancelled presence) never broadcasts.
6132
- */
6861
+ // src/transactions/controller.ts
6133
6862
  const encode = (tx) => new TextEncoder().encode(JSON.stringify(tx));
6134
6863
  const isObject = (v) => typeof v === "object" && v !== null;
6135
6864
  const TX_STATES = /* @__PURE__ */ new Set([
@@ -6149,11 +6878,12 @@ const isBtcRecord = (v) => {
6149
6878
  const { inputs, outputs, vsize, fee, feeRate } = v;
6150
6879
  return Array.isArray(inputs) && Array.isArray(outputs) && typeof vsize === "number" && typeof fee === "number" && typeof feeRate === "number" && inputs.every((i) => isObject(i) && typeof i["txid"] === "string" && typeof i["vout"] === "number" && typeof i["value"] === "number" && typeof i["address"] === "string") && outputs.every((o) => isObject(o) && typeof o["address"] === "string" && typeof o["value"] === "number" && typeof o["own"] === "boolean");
6151
6880
  };
6881
+ const isSparkDeposit = (v) => isObject(v) && typeof v["walletId"] === "string" && typeof v["groupIndex"] === "number" && (v["network"] === "mainnet" || v["network"] === "regtest") && (v["claimed"] === void 0 || typeof v["claimed"] === "boolean");
6152
6882
  const isIntent = (v) => isObject(v) && typeof v["summary"] === "string" && Array.isArray(v["outflows"]) && typeof v["verifiability"] === "string";
6153
6883
  const decode = (raw) => {
6154
6884
  const parsed = JSON.parse(new TextDecoder().decode(raw));
6155
6885
  if (!isObject(parsed)) return;
6156
- const { id, family, network, account, hash, state, intent, nonce, replaces, parentId, btc, submittedAt, error } = parsed;
6886
+ const { id, family, network, account, hash, state, intent, nonce, replaces, parentId, btc, sparkDeposit, submittedAt, error } = parsed;
6157
6887
  if (typeof id !== "string" || family !== "evm" && family !== "solana" && family !== "bitcoin" || typeof network !== "string" || typeof account !== "string" || !isTxState(state) || typeof nonce !== "number" || typeof submittedAt !== "number") return;
6158
6888
  return {
6159
6889
  id,
@@ -6167,6 +6897,7 @@ const decode = (raw) => {
6167
6897
  ...typeof replaces === "string" ? { replaces } : {},
6168
6898
  ...typeof parentId === "string" ? { parentId } : {},
6169
6899
  ...isBtcRecord(btc) ? { btc } : {},
6900
+ ...isSparkDeposit(sparkDeposit) ? { sparkDeposit } : {},
6170
6901
  submittedAt,
6171
6902
  error: typeof error === "string" ? error : void 0
6172
6903
  };
@@ -6255,16 +6986,77 @@ var TransactionController = class extends Controller {
6255
6986
  * signing it. Bitcoin outputs it selects stay locked under `requestId` until the returned
6256
6987
  * `release` runs or the broadcast moves them to the transaction.
6257
6988
  */
6258
- async prepareSend(params, requestId) {
6989
+ async prepareSend(params, requestId, sparkDeposit) {
6259
6990
  if (this.locked) throw new Error("transactions require an unlocked vault");
6260
6991
  const { account, network } = params;
6261
6992
  const info = this.options.networks.info(network);
6262
6993
  if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
6263
6994
  const ctx = this.routeCtx();
6264
6995
  if (info.family === "solana") return prepareSolana(ctx, params);
6265
- if (info.family === "bitcoin") return this.bitcoinRoute.prepare(ctx, params, requestId);
6996
+ if (info.family === "bitcoin") return this.bitcoinRoute.prepare(ctx, params, requestId, sparkDeposit);
6266
6997
  return this.evmRoute.prepare(ctx, params);
6267
6998
  }
6999
+ /** The group's next unused `btc.bip84` receive address on a bitcoin `network`. */
7000
+ async nextReceiveAddress(walletId, groupIndex, network) {
7001
+ if (this.locked) throw new Error("transactions require an unlocked vault");
7002
+ return this.bitcoinRoute.nextReceiveAddress(this.routeCtx(), walletId, groupIndex, network);
7003
+ }
7004
+ /** Records that a Spark deposit was credited; persisted with the transaction. */
7005
+ async markDepositClaimed(id) {
7006
+ const tx = this.getState().txs[id];
7007
+ if (tx?.sparkDeposit === void 0) throw new Error(`transaction ${id} is not a Spark deposit`);
7008
+ await this.transition(tx, { sparkDeposit: {
7009
+ ...tx.sparkDeposit,
7010
+ claimed: true
7011
+ } });
7012
+ }
7013
+ /**
7014
+ * Internal entry for a dApp's EVM signature request: the request is normalized first, a
7015
+ * transaction is rebuilt with fresh nonce and fees (the dApp's own are discarded), and a message
7016
+ * is decoded and signed as is.
7017
+ */
7018
+ async prepareDapp(account, request) {
7019
+ if (this.locked) throw new Error("transactions require an unlocked vault");
7020
+ const normalized = normalizeEvm(request);
7021
+ if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
7022
+ if (normalized.kind === "evm.transaction") return this.evmRoute.prepareCall(this.routeCtx(), {
7023
+ account,
7024
+ network: normalized.network,
7025
+ tx: normalized.tx
7026
+ });
7027
+ if (normalized.kind !== "evm.personal-message" && normalized.kind !== "evm.typed-data") throw new Error(`unsupported dApp request ${normalized.kind}`);
7028
+ const { kind, network } = normalized;
7029
+ const apis = this.options.chains ?? chains(this.options.wallet);
7030
+ return {
7031
+ account,
7032
+ network,
7033
+ intent: decodeEvm(normalized),
7034
+ checks: [],
7035
+ staleReason: () => void 0,
7036
+ release: () => void 0,
7037
+ submit: async (verify) => {
7038
+ const result = await apis.sign({
7039
+ request: normalized.kind === "evm.personal-message" ? {
7040
+ kind: normalized.kind,
7041
+ network,
7042
+ account,
7043
+ message: normalized.message
7044
+ } : {
7045
+ kind: normalized.kind,
7046
+ network,
7047
+ account,
7048
+ typedData: normalized.typedData
7049
+ },
7050
+ verify
7051
+ });
7052
+ if (result.kind !== kind || !("signature" in result)) throw new Error("unexpected sign result");
7053
+ return {
7054
+ kind,
7055
+ signature: result.signature
7056
+ };
7057
+ }
7058
+ };
7059
+ }
6268
7060
  /**
6269
7061
  * Internal entry for replacements and CPFP: builds, cross-checks and reserves what the
6270
7062
  * replacement needs without signing it. The original must be `submitted` or `pending`.
@@ -6315,8 +7107,7 @@ var TransactionController = class extends Controller {
6315
7107
  this.timers.set(id, stop);
6316
7108
  }
6317
7109
  };
6318
- //#endregion
6319
- //#region src/engine.ts
7110
+ // src/engine.ts
6320
7111
  /**
6321
7112
  * Composition root: wires the Wallet's lock state to the SparkController's lifecycle. Unlocked →
6322
7113
  * open the EngineStore and start every declared account; locked → abort event loops, close every
@@ -6409,11 +7200,44 @@ function createWalletEngine(options) {
6409
7200
  wallet: options.wallet,
6410
7201
  accounts,
6411
7202
  addressBook,
6412
- nostr: { signer: (ref) => nostr(options.wallet).signer(ref) },
7203
+ nostr: { syncSigner: (walletId) => nostr(options.wallet).syncSigner(walletId) },
6413
7204
  metaSync: options.metaSync,
6414
7205
  logger: options.logger,
6415
7206
  now: options.now
6416
7207
  });
7208
+ const depositClaimer = new DepositClaimer({
7209
+ transactions,
7210
+ spark: controller,
7211
+ networks,
7212
+ logger: options.logger
7213
+ });
7214
+ const connections = new ConnectionController({
7215
+ openStore: options.openStore,
7216
+ random: options.random,
7217
+ now: options.now,
7218
+ logger: options.logger,
7219
+ allowLocalhost: options.allowLocalhostOrigins ?? false,
7220
+ selectedGroup: () => accounts.getState().selected ?? void 0,
7221
+ evmAddress: async (group, network) => {
7222
+ const account = await chains(options.wallet).account({
7223
+ ...group,
7224
+ template: "evm.bip44",
7225
+ network
7226
+ });
7227
+ if (!("address" in account)) throw new Error("no EVM address for the group");
7228
+ return account.address;
7229
+ },
7230
+ enabledNetworks: () => networks.enabled("evm"),
7231
+ invalidate: (origin) => {
7232
+ requests.invalidateOrigin(origin);
7233
+ }
7234
+ });
7235
+ const eip1193 = new Eip1193Handler({
7236
+ connections,
7237
+ requests,
7238
+ networks,
7239
+ defaultNetwork: () => networks.enabled("evm")[0] ?? "eip155:1"
7240
+ });
6417
7241
  let chain = Promise.resolve();
6418
7242
  let store;
6419
7243
  let started = false;
@@ -6441,8 +7265,12 @@ function createWalletEngine(options) {
6441
7265
  await assets.init();
6442
7266
  await transactions.init();
6443
7267
  await requests.init();
7268
+ await connections.init();
7269
+ depositClaimer.start();
6444
7270
  } else if (!unlocked && started) {
6445
7271
  started = false;
7272
+ depositClaimer.stop();
7273
+ connections.lock();
6446
7274
  requests.lock();
6447
7275
  addressBook.lock();
6448
7276
  transactions.lock();
@@ -6486,6 +7314,8 @@ function createWalletEngine(options) {
6486
7314
  transactions,
6487
7315
  requests,
6488
7316
  addressBook,
7317
+ connections,
7318
+ eip1193,
6489
7319
  spark: controller,
6490
7320
  accounts,
6491
7321
  metaSync,
@@ -6513,8 +7343,7 @@ function createWalletEngine(options) {
6513
7343
  unsubscribe
6514
7344
  };
6515
7345
  }
6516
- //#endregion
6517
- //#region src/backup/memory.ts
7346
+ // src/backup/memory.ts
6518
7347
  const tick = Promise.resolve();
6519
7348
  /** In-memory CloudStore for tests and the playground; swap the Map for cross-engine sharing. */
6520
7349
  function createMemoryCloudStore(backing) {
@@ -6546,7 +7375,6 @@ function createMemoryCloudStore(backing) {
6546
7375
  }
6547
7376
  };
6548
7377
  }
6549
- //#endregion
6550
- export { AccountTreeController, AccountTreeError, AddressBookController, AddressBookError, BackupController, BackupControllerError, Controller, EngineStoreError, MetaSyncController, MetaSyncError, PaymentError, REQUEST_TTL_MS, RequestController, SparkController, SparkControllerError, createEventBus, createMemoryCloudStore, createMemoryEngineStore, createWalletEngine, decodeExitChain, encodeExitChain, parsePayment, prepareZap, resolvePayment, sparkStore };
7378
+ export { AccountTreeController, AccountTreeError, AddressBookController, AddressBookError, BackupController, BackupControllerError, ConnectionController, Controller, Eip1193Handler, EngineStoreError, MetaSyncController, MetaSyncError, PaymentError, ProviderError, REQUEST_TTL_MS, RequestController, SparkController, SparkControllerError, createEventBus, createMemoryCloudStore, createMemoryEngineStore, createWalletEngine, decodeExitChain, encodeExitChain, parsePayment, prepareZap, resolvePayment, sparkStore };
6551
7379
 
6552
7380
  //# sourceMappingURL=index.mjs.map