@kaleidorg/wallet-engine 1.0.0-beta.32 → 1.0.0-beta.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/adapters/SparkAdapter.d.ts +93 -16
  2. package/dist/adapters/SparkAdapter.d.ts.map +1 -1
  3. package/dist/adapters/SparkAdapter.js +831 -171
  4. package/dist/adapters/SparkAdapter.js.map +1 -1
  5. package/dist/adapters/arkade.d.ts +15 -0
  6. package/dist/adapters/arkade.d.ts.map +1 -0
  7. package/dist/adapters/arkade.js +15 -0
  8. package/dist/adapters/arkade.js.map +1 -0
  9. package/dist/adapters/spark.d.ts +11 -0
  10. package/dist/adapters/spark.d.ts.map +1 -0
  11. package/dist/adapters/spark.js +11 -0
  12. package/dist/adapters/spark.js.map +1 -0
  13. package/dist/lib/psbt-signer.d.ts +60 -0
  14. package/dist/lib/psbt-signer.d.ts.map +1 -0
  15. package/dist/lib/psbt-signer.js +161 -0
  16. package/dist/lib/psbt-signer.js.map +1 -0
  17. package/dist/lib/spark-activity.d.ts +5 -0
  18. package/dist/lib/spark-activity.d.ts.map +1 -0
  19. package/dist/lib/spark-activity.js +11 -0
  20. package/dist/lib/spark-activity.js.map +1 -0
  21. package/dist/lib/spark-balance-cache.d.ts +58 -0
  22. package/dist/lib/spark-balance-cache.d.ts.map +1 -0
  23. package/dist/lib/spark-balance-cache.js +86 -0
  24. package/dist/lib/spark-balance-cache.js.map +1 -0
  25. package/dist/lib/spark-client-manager.d.ts +54 -9
  26. package/dist/lib/spark-client-manager.d.ts.map +1 -1
  27. package/dist/lib/spark-client-manager.js +176 -35
  28. package/dist/lib/spark-client-manager.js.map +1 -1
  29. package/dist/lib/spark-converters.d.ts +64 -0
  30. package/dist/lib/spark-converters.d.ts.map +1 -0
  31. package/dist/lib/spark-converters.js +242 -0
  32. package/dist/lib/spark-converters.js.map +1 -0
  33. package/dist/lib/spark-helpers.d.ts +72 -0
  34. package/dist/lib/spark-helpers.d.ts.map +1 -0
  35. package/dist/lib/spark-helpers.js +151 -0
  36. package/dist/lib/spark-helpers.js.map +1 -0
  37. package/dist/lib/spark-sent-token-records.d.ts +43 -0
  38. package/dist/lib/spark-sent-token-records.d.ts.map +1 -0
  39. package/dist/lib/spark-sent-token-records.js +105 -0
  40. package/dist/lib/spark-sent-token-records.js.map +1 -0
  41. package/package.json +11 -2
@@ -1,19 +1,63 @@
1
1
  /**
2
2
  * Spark Protocol Adapter
3
- * Implements IProtocolAdapter using @buildonspark/spark-sdk.
4
- * Ported from rate-extension for React Native.
3
+ * Implements IProtocolAdapter using @buildonspark/spark-sdk (native Spark SDK).
4
+ *
5
+ * Native SDK API reference:
6
+ * - SparkWallet.initialize({ mnemonicOrSeed, options: { network } })
7
+ * - wallet.getBalance() → { balance: bigint }
8
+ * - wallet.getSparkAddress() → SparkAddressFormat (string)
9
+ * - wallet.getSingleUseDepositAddress() → string (BTC on-chain)
10
+ * - wallet.createLightningInvoice({ amountSats, memo? }) → { invoice: { encodedInvoice } }
11
+ * - wallet.payLightningInvoice({ invoice, maxFeeSats }) → LightningSendRequest | WalletTransfer
12
+ * - wallet.transfer({ receiverSparkAddress, amountSats }) → WalletTransfer
13
+ * - wallet.withdraw({ onchainAddress, amountSats, exitSpeed }) → withdrawal result
14
+ * - wallet.getTransfers(limit?, offset?, createdAfter?, createdBefore?) → { transfers: WalletTransfer[], offset: number }
15
+ * - wallet.getTransfer(id) → WalletTransfer | undefined
16
+ * - wallet.cleanupConnections() → void
17
+ *
18
+ * Amounts in the SDK are in SATS. Balance is returned as bigint.
5
19
  */
6
- import { sparkClientManager } from '../lib/spark-client-manager.js';
7
- import { ProtocolError, ConnectionError, } from '../types/base.js';
8
- import { PROTOCOL_OPERATIONS } from '../capabilities/operations.js';
20
+ import { ExitSpeed } from "@buildonspark/spark-sdk/types";
21
+ import { isValidSparkAddress, decodeSparkAddress, getNetworkFromSparkAddress, } from "@buildonspark/spark-sdk";
22
+ import { log } from "../lib/log.js";
23
+ import { loadSentTokenRecords, normalizeTxHash, saveSentTokenRecord, } from "../lib/spark-sent-token-records.js";
24
+ import { sparkClientManager } from "../lib/spark-client-manager.js";
25
+ import { PROTOCOL_OPERATIONS } from "../capabilities/operations.js";
26
+ import { ProtocolError, ConnectionError, } from "../types/base.js";
27
+ import { mnemonicToSeedSync } from "@scure/bip39";
28
+ import { HDKey } from "@scure/bip32";
29
+ import { signLnMessage, verifyLnMessage } from "../lib/ln-message-sign.js";
30
+ /** Default maximum fee for Lightning payments (sats). */
9
31
  const DEFAULT_MAX_FEE_SATS = 1000;
32
+ // Pure helpers — timeout wrapper, byte/hex/token utilities, expiry parsing,
33
+ // isEmptyBalance — live in ./helpers.ts. Balance cache state +
34
+ // getSparkBalanceCached / invalidateSparkBalanceCache live in
35
+ // ./balance-cache.ts. Both are re-exported here so existing call sites
36
+ // that import isEmptyBalance / invalidateSparkBalanceCache keep working.
37
+ import { formatAmount, mapTransferStatus, parseSdkExpiryMs, rawTokenIdFromBech32mTokenId, rawTokenIdFromBytes, tokenRefsMatch, txHashFromBytes, withTimeout, } from "../lib/spark-helpers.js";
38
+ import { getSparkBalanceCached, invalidateSparkBalanceCache, SPARK_RPC_TIMEOUT_MS, } from "../lib/spark-balance-cache.js";
39
+ import { buildSentRecordTransaction, convertTokenTransactionToUnified, convertTransferToTransaction, } from "../lib/spark-converters.js";
40
+ export { isEmptyBalance } from "../lib/spark-helpers.js";
41
+ export { invalidateSparkBalanceCache };
42
+ /**
43
+ * Spark Protocol Adapter Implementation
44
+ */
10
45
  export class SparkAdapter {
11
46
  constructor() {
12
- this.protocolName = 'SPARK';
47
+ this.protocolName = "SPARK";
48
+ this.supportedLayers = ["SPARK_SPARK", "BTC_LN"];
49
+ this.version = "1.0.0";
13
50
  this.capabilities = PROTOCOL_OPERATIONS.SPARK;
14
- this.supportedLayers = ['SPARK_SPARK', 'BTC_LN'];
15
- this.version = '1.0.0';
16
51
  this.config = null;
52
+ /** Maps Lightning invoice string → LightningReceiveRequest ID for status polling. */
53
+ this.invoiceRequestIds = new Map();
54
+ // ========================================================================
55
+ // Helper Methods
56
+ // ========================================================================
57
+ // Pure helpers (mapTransferStatus, formatAmount, byte/hex/token utilities)
58
+ // live in ./helpers.ts; SDK↔unified converters live in ./converters.ts.
59
+ // Covered by tests/unit/spark-helpers.test.ts +
60
+ // tests/unit/spark-converters.test.ts.
17
61
  }
18
62
  // ========================================================================
19
63
  // Connection Management
@@ -21,41 +65,43 @@ export class SparkAdapter {
21
65
  async connect(config) {
22
66
  const sparkConfig = config;
23
67
  if (!sparkConfig.mnemonic) {
24
- throw new ConnectionError('Mnemonic is required for Spark wallet', 'SPARK');
68
+ throw new ConnectionError("Mnemonic is required for Spark wallet", "SPARK");
25
69
  }
26
70
  try {
27
71
  await sparkClientManager.initialize(sparkConfig);
28
72
  this.config = sparkConfig;
29
- console.log('[SparkAdapter] Connected to Spark successfully');
73
+ log.info("[SparkAdapter] Connected to Spark successfully");
30
74
  }
31
75
  catch (error) {
32
- throw new ConnectionError(`Failed to connect to Spark: ${error.message}`, 'SPARK', error);
76
+ const msg = error instanceof Error ? error.message : String(error);
77
+ throw new ConnectionError(`Failed to connect to Spark: ${msg}`, "SPARK", error);
33
78
  }
34
79
  }
35
80
  async disconnect() {
36
81
  await sparkClientManager.disconnect();
37
82
  this.config = null;
38
- console.log('[SparkAdapter] Disconnected from Spark');
83
+ log.info("[SparkAdapter] Disconnected from Spark");
39
84
  }
40
85
  isConnected() {
41
86
  return sparkClientManager.isInitialized();
42
87
  }
43
88
  async getConnectionInfo() {
44
89
  if (!this.isConnected()) {
45
- throw new ProtocolError('Not connected', 'SPARK', 'NOT_CONNECTED');
90
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
46
91
  }
47
92
  try {
48
93
  const wallet = sparkClientManager.getWallet();
49
- await wallet.getBalance();
94
+ await getSparkBalanceCached(wallet);
50
95
  return {
51
- protocol: 'SPARK',
96
+ protocol: "SPARK",
52
97
  connected: true,
53
- network: this.config?.network || 'regtest',
98
+ network: this.config?.network || "regtest",
54
99
  syncStatus: { synced: true, progress: 100 },
55
100
  };
56
101
  }
57
102
  catch (error) {
58
- throw new ProtocolError(`Failed to get connection info: ${error.message}`, 'SPARK', 'CONNECTION_INFO_ERROR', error);
103
+ const msg = error instanceof Error ? error.message : String(error);
104
+ throw new ProtocolError(`Failed to get connection info: ${msg}`, "SPARK", "CONNECTION_INFO_ERROR", error);
59
105
  }
60
106
  }
61
107
  // ========================================================================
@@ -63,26 +109,26 @@ export class SparkAdapter {
63
109
  // ========================================================================
64
110
  async listAssets() {
65
111
  if (!this.isConnected()) {
66
- throw new ProtocolError('Not connected', 'SPARK', 'NOT_CONNECTED');
112
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
67
113
  }
68
114
  try {
69
115
  const wallet = sparkClientManager.getWallet();
70
- const { balance, tokenBalances } = await wallet.getBalance();
116
+ const { balance, tokenBalances } = await getSparkBalanceCached(wallet);
71
117
  const balanceSats = Number(balance);
72
118
  const btcAsset = {
73
- id: 'BTC',
74
- name: 'Bitcoin',
75
- ticker: 'BTC',
119
+ id: "BTC",
120
+ name: "Bitcoin",
121
+ ticker: "BTC",
76
122
  precision: 8,
77
- protocol: 'SPARK',
78
- layer: 'SPARK_SPARK',
123
+ protocol: "SPARK",
124
+ layer: "SPARK_SPARK",
79
125
  balance: {
80
126
  total: balanceSats,
81
127
  available: balanceSats,
82
128
  pending: 0,
83
129
  locked: 0,
84
- totalDisplay: this.formatAmount(balanceSats, 8),
85
- availableDisplay: this.formatAmount(balanceSats, 8),
130
+ totalDisplay: formatAmount(balanceSats, 8),
131
+ availableDisplay: formatAmount(balanceSats, 8),
86
132
  },
87
133
  capabilities: {
88
134
  canSend: true,
@@ -93,27 +139,28 @@ export class SparkAdapter {
93
139
  },
94
140
  };
95
141
  const assets = [btcAsset];
96
- // Add token assets
142
+ // Add token assets from Spark's BTKN token standard
97
143
  if (tokenBalances && tokenBalances.size > 0) {
98
144
  for (const [tokenId, info] of tokenBalances) {
99
145
  const { tokenMetadata: meta } = info;
100
146
  const owned = Number(info.ownedBalance);
101
147
  const available = Number(info.availableToSendBalance);
102
- const precision = meta.decimals;
148
+ const precision = meta.decimals ?? 8;
103
149
  assets.push({
104
150
  id: tokenId,
105
151
  name: meta.tokenName,
106
152
  ticker: meta.tokenTicker,
153
+ icon: meta.tokenImageUrl,
107
154
  precision,
108
- protocol: 'SPARK',
109
- layer: 'SPARK_SPARK',
155
+ protocol: "SPARK",
156
+ layer: "SPARK_SPARK",
110
157
  balance: {
111
158
  total: owned,
112
159
  available,
113
160
  pending: 0,
114
161
  locked: owned - available,
115
- totalDisplay: this.formatAmount(owned, precision),
116
- availableDisplay: this.formatAmount(available, precision),
162
+ totalDisplay: formatAmount(owned, precision),
163
+ availableDisplay: formatAmount(available, precision),
117
164
  },
118
165
  capabilities: {
119
166
  canSend: true,
@@ -128,14 +175,15 @@ export class SparkAdapter {
128
175
  return assets;
129
176
  }
130
177
  catch (error) {
131
- throw new ProtocolError(`Failed to list assets: ${error.message}`, 'SPARK', 'LIST_ASSETS_ERROR');
178
+ const msg = error instanceof Error ? error.message : String(error);
179
+ throw new ProtocolError(`Failed to list assets: ${msg}`, "SPARK", "LIST_ASSETS_ERROR", error);
132
180
  }
133
181
  }
134
182
  async getAsset(assetId) {
135
183
  const assets = await this.listAssets();
136
- const asset = assets.find(a => a.id === assetId || a.ticker === assetId);
184
+ const asset = assets.find((a) => a.id === assetId || a.ticker === assetId);
137
185
  if (!asset) {
138
- throw new ProtocolError(`Asset not found: ${assetId}`, 'SPARK', 'ASSET_NOT_FOUND');
186
+ throw new ProtocolError(`Asset not found: ${assetId}`, "SPARK", "ASSET_NOT_FOUND");
139
187
  }
140
188
  return asset;
141
189
  }
@@ -144,44 +192,223 @@ export class SparkAdapter {
144
192
  return asset.balance;
145
193
  }
146
194
  async refreshBalances() {
147
- // Balances are fetched live
195
+ // Drop the short-TTL coalescing cache so the next call hits the gateway
196
+ // for a fresh snapshot. The cache only exists to collapse the burst of
197
+ // simultaneous reads from a single dashboard render.
198
+ invalidateSparkBalanceCache();
148
199
  }
149
200
  // ========================================================================
150
201
  // Transaction Operations
151
202
  // ========================================================================
152
203
  async listTransactions(filter) {
153
204
  if (!this.isConnected()) {
154
- throw new ProtocolError('Not connected', 'SPARK', 'NOT_CONNECTED');
205
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
155
206
  }
156
207
  try {
157
208
  const wallet = sparkClientManager.getWallet();
158
- const { transfers } = await wallet.getTransfers(filter?.limit || 50, filter?.offset || 0);
159
- return (transfers || []).map((t) => this.convertTransfer(t)).filter(Boolean);
209
+ const limit = filter?.limit ?? 20;
210
+ const offset = filter?.offset ?? 0;
211
+ const requestedAsset = filter?.asset?.trim();
212
+ const createdAfter = filter?.fromTimestamp ? new Date(filter.fromTimestamp) : undefined;
213
+ const createdBefore = !createdAfter && filter?.toTimestamp ? new Date(filter.toTimestamp) : undefined;
214
+ const shouldFetchBtc = !requestedAsset || requestedAsset === "BTC";
215
+ const shouldFetchTokens = !requestedAsset || requestedAsset !== "BTC";
216
+ const requestedTokenRawId = requestedAsset && requestedAsset !== "BTC"
217
+ ? rawTokenIdFromBech32mTokenId(requestedAsset)
218
+ : "";
219
+ // Fetch BTC transfers — best effort. A gateway/auth failure here must
220
+ // not hide token activity, and especially not the offline send-record
221
+ // fallback below.
222
+ let btcTxs = [];
223
+ if (shouldFetchBtc) {
224
+ try {
225
+ let btcTransfers = [];
226
+ if (createdAfter || createdBefore) {
227
+ const readonlyClient = await sparkClientManager.getReadonlyClient();
228
+ const sparkAddress = (await wallet.getSparkAddress());
229
+ const readonlyResult = await readonlyClient.getTransfers({
230
+ sparkAddress,
231
+ limit,
232
+ offset,
233
+ createdAfter,
234
+ createdBefore,
235
+ });
236
+ const hydratedTransfers = await Promise.all(readonlyResult.transfers.map((transfer) => wallet.getTransfer(transfer.id)));
237
+ btcTransfers = hydratedTransfers.filter((transfer) => !!transfer);
238
+ }
239
+ else {
240
+ btcTransfers = (await wallet.getTransfers(limit, offset)).transfers;
241
+ }
242
+ btcTxs = btcTransfers.map((t) => convertTransferToTransaction(t));
243
+ }
244
+ catch (err) {
245
+ log.warn("[SparkAdapter] Failed to fetch BTC transfers:", err);
246
+ }
247
+ }
248
+ // Fetch token transactions. Every Spark RPC below is best-effort and
249
+ // isolated — a transport/auth failure must never hide locally-recorded
250
+ // sends, which are the only reliable record of an outgoing token
251
+ // transfer (a withdrawal with no change output is invisible to the
252
+ // owner-filtered server query).
253
+ const tokenTxs = [];
254
+ try {
255
+ if (shouldFetchTokens) {
256
+ const sparkAddress = (await wallet.getSparkAddress());
257
+ const identityPubKey = await wallet.getIdentityPublicKey();
258
+ let networkType = "";
259
+ try {
260
+ networkType = getNetworkFromSparkAddress(sparkAddress);
261
+ }
262
+ catch {
263
+ // Non-fatal — networkType only feeds bech32m encoding fallbacks.
264
+ }
265
+ // Token metadata lookup from current balances — best effort. Empty
266
+ // when the balance is 0 or the balance RPC fails; the converter and
267
+ // the stored-record fallback both tolerate missing metadata.
268
+ const tokenMetaMap = new Map();
269
+ const rawTokenMetaMap = new Map();
270
+ try {
271
+ const { tokenBalances } = await wallet.getBalance();
272
+ if (tokenBalances) {
273
+ for (const [tokenId, info] of tokenBalances) {
274
+ const meta = {
275
+ name: info.tokenMetadata.tokenName,
276
+ ticker: info.tokenMetadata.tokenTicker,
277
+ decimals: info.tokenMetadata.decimals,
278
+ };
279
+ tokenMetaMap.set(tokenId, meta);
280
+ const rawTokenIdentifier = info.tokenMetadata.rawTokenIdentifier;
281
+ const rawTokenId = rawTokenIdFromBytes(rawTokenIdentifier);
282
+ if (rawTokenId) {
283
+ rawTokenMetaMap.set(rawTokenId, { id: tokenId, meta });
284
+ }
285
+ }
286
+ }
287
+ }
288
+ catch (err) {
289
+ log.warn("[SparkAdapter] Failed to load token balances for activity:", err);
290
+ }
291
+ // Stored send records — written to chrome.storage at send time, so
292
+ // they are available even when the Spark gateway is unreachable.
293
+ const allSentRecords = await loadSentTokenRecords();
294
+ const walletSentRecords = allSentRecords.filter((record) => record.senderSparkAddress === sparkAddress);
295
+ const sentRecords = requestedAsset && requestedAsset !== "BTC"
296
+ ? walletSentRecords.filter((record) => tokenRefsMatch(record.assetId, requestedAsset))
297
+ : walletSentRecords;
298
+ const sentHashSet = new Set(sentRecords.map((r) => normalizeTxHash(r.hash)));
299
+ const storedRecordMap = new Map(sentRecords.map((r) => [normalizeTxHash(r.hash), r]));
300
+ const storedAmountMap = new Map(sentRecords.map((r) => [normalizeTxHash(r.hash), BigInt(Math.round(r.amount || 0))]));
301
+ // Server-side history — best effort, isolated from the fallback.
302
+ // Uses the owner-keyed `queryTokenTransactions`, which returns
303
+ // complete output owners and amounts. That lets the converter
304
+ // derive direction from output ownership (see
305
+ // convertTokenTransactionToUnified) — the protocol exposes no
306
+ // direction field for token transactions.
307
+ const txsWithStatus = [];
308
+ try {
309
+ const result = await wallet.queryTokenTransactions({
310
+ ownerPublicKeys: [identityPubKey],
311
+ tokenIdentifiers: requestedAsset && requestedAsset !== "BTC" ? [requestedAsset] : undefined,
312
+ pageSize: limit,
313
+ });
314
+ txsWithStatus.push(...(result.tokenTransactionsWithStatus ?? []));
315
+ }
316
+ catch (err) {
317
+ log.warn("[SparkAdapter] Failed to query token transactions:", err);
318
+ }
319
+ // Sends without a change output are invisible to the owner-filtered
320
+ // query above; fetch them explicitly by hash. Also best effort.
321
+ if (sentRecords.length > 0) {
322
+ try {
323
+ const sentResult = await wallet.queryTokenTransactionsByTxHashes(sentRecords.map((r) => normalizeTxHash(r.hash)));
324
+ const existingHashes = new Set(txsWithStatus.map((t) => txHashFromBytes(t.tokenTransactionHash)));
325
+ for (const sentTx of sentResult.tokenTransactionsWithStatus ?? []) {
326
+ const hash = txHashFromBytes(sentTx.tokenTransactionHash);
327
+ if (!existingHashes.has(hash)) {
328
+ txsWithStatus.push(sentTx);
329
+ }
330
+ }
331
+ }
332
+ catch (err) {
333
+ log.warn("[SparkAdapter] Failed to fetch sent token transactions:", err);
334
+ }
335
+ }
336
+ // Convert whatever the gateway returned, tracking which recorded
337
+ // sends were successfully rendered.
338
+ const renderedSendHashes = new Set();
339
+ for (const txWithStatus of txsWithStatus) {
340
+ const converted = convertTokenTransactionToUnified(txWithStatus, identityPubKey, tokenMetaMap, rawTokenMetaMap, sentHashSet, storedRecordMap, storedAmountMap, networkType, requestedAsset && requestedAsset !== "BTC" ? requestedAsset : undefined, requestedTokenRawId);
341
+ if (converted) {
342
+ tokenTxs.push(converted);
343
+ const hash = txHashFromBytes(txWithStatus.tokenTransactionHash);
344
+ if (sentHashSet.has(hash))
345
+ renderedSendHashes.add(hash);
346
+ }
347
+ }
348
+ // Offline / failed-fetch fallback: synthesize a transaction directly
349
+ // from any recorded send the gateway did not return, so a completed
350
+ // withdrawal always shows up in history.
351
+ let synthesizedCount = 0;
352
+ for (const record of sentRecords) {
353
+ const hash = normalizeTxHash(record.hash);
354
+ if (renderedSendHashes.has(hash))
355
+ continue;
356
+ tokenTxs.push(buildSentRecordTransaction(record, requestedAsset && requestedAsset !== "BTC" ? requestedAsset : undefined));
357
+ synthesizedCount += 1;
358
+ }
359
+ // Diagnostic: surfaces whether the send outbox is populated. If a
360
+ // withdrawal is missing from history and these counts are 0, the
361
+ // send was never recorded (e.g. performed on a pre-outbox build).
362
+ log.info(`[SparkAdapter] token activity: ${tokenTxs.length} tx, ` +
363
+ `${allSentRecords.length} stored sends ` +
364
+ `(${sentRecords.length} this wallet, ${renderedSendHashes.size} from gateway, ` +
365
+ `${synthesizedCount} synthesized)`);
366
+ }
367
+ }
368
+ catch (err) {
369
+ log.warn("[SparkAdapter] Failed to fetch token transactions:", err);
370
+ }
371
+ const allTxs = [...btcTxs, ...tokenTxs].sort((a, b) => b.timestamp - a.timestamp);
372
+ return allTxs.filter((tx) => {
373
+ if (!filter)
374
+ return true;
375
+ if (filter.asset &&
376
+ tx.asset?.id !== filter.asset &&
377
+ tx.asset?.ticker !== filter.asset &&
378
+ !tokenRefsMatch(tx.asset?.id, filter.asset))
379
+ return false;
380
+ if (filter.type && tx.type !== filter.type)
381
+ return false;
382
+ if (filter.status && tx.status !== filter.status)
383
+ return false;
384
+ if (filter.fromTimestamp && tx.timestamp < filter.fromTimestamp)
385
+ return false;
386
+ if (filter.toTimestamp && tx.timestamp > filter.toTimestamp)
387
+ return false;
388
+ return true;
389
+ });
160
390
  }
161
391
  catch (error) {
162
- throw new ProtocolError(`Failed to list transactions: ${error.message}`, 'SPARK', 'LIST_TRANSACTIONS_ERROR');
392
+ const msg = error instanceof Error ? error.message : String(error);
393
+ throw new ProtocolError(`Failed to list transactions: ${msg}`, "SPARK", "LIST_TRANSACTIONS_ERROR", error);
163
394
  }
164
395
  }
165
396
  async getTransaction(txId) {
166
397
  if (!this.isConnected()) {
167
- throw new ProtocolError('Not connected', 'SPARK', 'NOT_CONNECTED');
398
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
168
399
  }
169
400
  try {
170
401
  const wallet = sparkClientManager.getWallet();
171
- const transfer = await wallet.getTransfer(txId);
402
+ const transfer = (await wallet.getTransfer(txId));
172
403
  if (!transfer) {
173
- throw new ProtocolError(`Transaction not found: ${txId}`, 'SPARK', 'TX_NOT_FOUND');
404
+ throw new ProtocolError(`Transaction not found: ${txId}`, "SPARK", "TX_NOT_FOUND");
174
405
  }
175
- const converted = this.convertTransfer(transfer);
176
- if (!converted) {
177
- throw new ProtocolError(`Failed to convert transaction: ${txId}`, 'SPARK', 'TX_CONVERT_ERROR');
178
- }
179
- return converted;
406
+ return convertTransferToTransaction(transfer);
180
407
  }
181
408
  catch (error) {
182
409
  if (error instanceof ProtocolError)
183
410
  throw error;
184
- throw new ProtocolError(`Failed to get transaction: ${error.message}`, 'SPARK', 'GET_TX_ERROR');
411
+ throw new ProtocolError(`Transaction not found: ${txId}`, "SPARK", "TX_NOT_FOUND", error);
185
412
  }
186
413
  }
187
414
  // ========================================================================
@@ -189,207 +416,640 @@ export class SparkAdapter {
189
416
  // ========================================================================
190
417
  async createInvoice(request) {
191
418
  if (!this.isConnected()) {
192
- throw new ProtocolError('Not connected', 'SPARK', 'NOT_CONNECTED');
419
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
420
+ }
421
+ if (request.asset && request.asset !== "BTC") {
422
+ throw new ProtocolError("Spark only supports BTC invoices", "SPARK", "UNSUPPORTED_ASSET");
193
423
  }
194
424
  try {
195
425
  const wallet = sparkClientManager.getWallet();
196
426
  const result = await wallet.createLightningInvoice({
197
- amountSats: request.amount || 0,
427
+ amountSats: request.amount ?? 0,
198
428
  memo: request.description,
429
+ expirySeconds: request.expirySeconds,
199
430
  });
200
- const invoice = result.invoice;
431
+ const inv = result.invoice;
432
+ const encodedInvoice = inv.encodedInvoice;
433
+ // Store request ID for invoice status polling
434
+ if (result.id && encodedInvoice) {
435
+ this.invoiceRequestIds.set(encodedInvoice, result.id);
436
+ }
437
+ const expiresAt = parseSdkExpiryMs("expiryTime" in inv
438
+ ? inv.expiryTime
439
+ : "expiresAt" in inv
440
+ ? inv.expiresAt
441
+ : undefined);
201
442
  return {
202
- invoice: invoice.encodedInvoice,
203
- paymentHash: invoice.paymentHash || '',
443
+ invoice: encodedInvoice,
444
+ paymentHash: inv.paymentHash ?? "",
204
445
  amount: request.amount,
205
- expiresAt: invoice.expiryTime ? new Date(invoice.expiryTime).getTime() : Date.now() + 3600000,
446
+ expiresAt: expiresAt ?? Date.now() + (request.expirySeconds ?? 3600) * 1000,
206
447
  description: request.description,
207
448
  };
208
449
  }
209
450
  catch (error) {
210
- throw new ProtocolError(`Failed to create invoice: ${error.message}`, 'SPARK', 'CREATE_INVOICE_ERROR');
451
+ const msg = error instanceof Error ? error.message : String(error);
452
+ throw new ProtocolError(`Failed to create invoice: ${msg}`, "SPARK", "CREATE_INVOICE_ERROR", error);
211
453
  }
212
454
  }
213
- async decodeInvoice(invoice) {
214
- // Spark SDK doesn't have a decode method; return basic parsed info
455
+ async createSparkInvoice(request) {
456
+ if (!this.isConnected()) {
457
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
458
+ }
459
+ try {
460
+ const wallet = sparkClientManager.getWallet();
461
+ const invoice = await wallet.createSatsInvoice({
462
+ amount: request.amount || undefined,
463
+ memo: request.description,
464
+ expiryTime: request.expirySeconds
465
+ ? new Date(Date.now() + request.expirySeconds * 1000)
466
+ : undefined,
467
+ });
468
+ return {
469
+ invoice: invoice,
470
+ paymentHash: "",
471
+ amount: request.amount,
472
+ expiresAt: Date.now() + (request.expirySeconds ?? 3600) * 1000,
473
+ description: request.description,
474
+ };
475
+ }
476
+ catch (error) {
477
+ const msg = error instanceof Error ? error.message : String(error);
478
+ throw new ProtocolError(`Failed to create Spark invoice: ${msg}`, "SPARK", "CREATE_SPARK_INVOICE_ERROR", error);
479
+ }
480
+ }
481
+ async decodeInvoice(input) {
482
+ // Detect payment type without SDK parse() — bolt11 starts with "ln"
483
+ const lower = input.trim().toLowerCase();
484
+ if (lower.startsWith("ln")) {
485
+ // bolt11 invoice: decode fields heuristically
486
+ return {
487
+ paymentHash: "",
488
+ expiresAt: 0,
489
+ destination: input,
490
+ };
491
+ }
492
+ // Spark address or BTC address
215
493
  return {
216
- paymentHash: '',
494
+ paymentHash: "",
217
495
  expiresAt: 0,
218
- destination: invoice,
496
+ destination: input,
219
497
  };
220
498
  }
221
499
  async sendPayment(request) {
222
500
  if (!this.isConnected()) {
223
- throw new ProtocolError('Not connected', 'SPARK', 'NOT_CONNECTED');
501
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
224
502
  }
225
503
  try {
226
504
  const wallet = sparkClientManager.getWallet();
227
- // Detect if this is a Lightning invoice or Spark address
228
- const isLightning = request.invoice.toLowerCase().startsWith('ln');
229
- let result;
230
- if (isLightning) {
231
- result = await wallet.payLightningInvoice({
232
- invoice: request.invoice,
233
- maxFeeSats: DEFAULT_MAX_FEE_SATS,
234
- // Required by the Spark SDK for 0-amount (amountless) invoices; for
235
- // invoices that already carry an amount this must be omitted.
505
+ const destination = request.invoice.trim();
506
+ const lower = destination.toLowerCase();
507
+ if (lower.startsWith("ln")) {
508
+ // Lightning payment — payLightningInvoice is synchronous: when it
509
+ // returns without throwing the payment has been dispatched. The
510
+ // result is a LightningSendRequest whose ID is *not* queryable via
511
+ // getTransfer, so we treat a successful return as 'confirmed' to
512
+ // avoid the polling path (which would fail with "Payment not found").
513
+ const extReq = request;
514
+ // For amountless ("0-sat") BOLT-11 invoices the Spark SDK requires
515
+ // `amountSatsToSend` to be passed explicitly. We always forward
516
+ // `request.amount` when present so amountless invoices can be paid
517
+ // with the user-entered amount.
518
+ const result = await wallet.payLightningInvoice({
519
+ invoice: destination,
520
+ maxFeeSats: extReq.maxFee ?? DEFAULT_MAX_FEE_SATS,
236
521
  ...(request.amount && request.amount > 0 ? { amountSatsToSend: request.amount } : {}),
237
522
  });
523
+ const lnResult = result;
524
+ const id = String(lnResult.id ?? "");
525
+ const amountSats = Number(lnResult.amountSats ?? lnResult.totalValue ?? request.amount ?? 0);
526
+ const feeSats = Number(lnResult.feeSats ?? 0);
527
+ const rawStatus = mapTransferStatus(lnResult.status);
528
+ return {
529
+ paymentHash: id,
530
+ amount: amountSats,
531
+ fee: feeSats,
532
+ // If the call returned without throwing, treat as confirmed
533
+ // (Lightning payments settle atomically).
534
+ status: rawStatus === "failed" ? "failed" : "confirmed",
535
+ timestamp: lnResult.createdTime instanceof Date ? lnResult.createdTime.getTime() : Date.now(),
536
+ };
238
537
  }
239
- else {
240
- // Spark-to-Spark transfer
241
- result = await wallet.transfer({
242
- receiverSparkAddress: request.invoice,
243
- amountSats: request.amount || 0,
244
- });
538
+ // Spark address or Spark invoice
539
+ if (isValidSparkAddress(destination)) {
540
+ // Distinguish a plain Spark address from a Spark invoice by checking for sparkInvoiceFields
541
+ const network = getNetworkFromSparkAddress(destination);
542
+ const decoded = decodeSparkAddress(destination, network);
543
+ if (decoded.sparkInvoiceFields) {
544
+ // Spark invoice — use fulfillSparkInvoice
545
+ const response = await wallet.fulfillSparkInvoice([
546
+ {
547
+ invoice: destination,
548
+ amount: request.amount ? BigInt(request.amount) : undefined,
549
+ },
550
+ ]);
551
+ if (response.satsTransactionErrors.length > 0) {
552
+ throw new Error(response.satsTransactionErrors[0].error.message);
553
+ }
554
+ const success = response.satsTransactionSuccess[0];
555
+ if (!success) {
556
+ throw new Error("Spark invoice payment returned no result");
557
+ }
558
+ const transfer = success.transferResponse;
559
+ return {
560
+ paymentHash: transfer.id,
561
+ amount: transfer.totalValue,
562
+ fee: 0,
563
+ status: mapTransferStatus(transfer.status),
564
+ timestamp: transfer.createdTime?.getTime() ?? Date.now(),
565
+ };
566
+ }
567
+ // Plain Spark address — use transfer
568
+ const transfer = (await wallet.transfer({
569
+ receiverSparkAddress: destination,
570
+ amountSats: request.amount ?? 0,
571
+ }));
572
+ return {
573
+ paymentHash: transfer.id,
574
+ amount: transfer.totalValue,
575
+ fee: 0,
576
+ status: mapTransferStatus(transfer.status),
577
+ timestamp: transfer.createdTime?.getTime() ?? Date.now(),
578
+ };
579
+ }
580
+ // On-chain BTC withdrawal — requires a fee quote first
581
+ const feeQuote = await wallet.getWithdrawalFeeQuote({
582
+ amountSats: request.amount ?? 0,
583
+ withdrawalAddress: destination,
584
+ });
585
+ if (!feeQuote) {
586
+ throw new Error("Failed to get withdrawal fee quote for on-chain exit");
245
587
  }
588
+ const feeAmountSats = (feeQuote.l1BroadcastFeeMedium?.originalValue ?? 0) +
589
+ (feeQuote.userFeeMedium?.originalValue ?? 0);
590
+ const result = await wallet.withdraw({
591
+ onchainAddress: destination,
592
+ amountSats: request.amount ?? 0,
593
+ exitSpeed: ExitSpeed.MEDIUM,
594
+ feeQuoteId: feeQuote.id,
595
+ feeAmountSats,
596
+ });
246
597
  return {
247
- paymentHash: result?.id || '',
248
- amount: request.amount || 0,
249
- fee: 0,
250
- status: 'confirmed',
598
+ paymentHash: result?.id ?? "",
599
+ amount: request.amount ?? 0,
600
+ fee: result?.fee?.originalValue ?? 0,
601
+ status: "pending",
251
602
  timestamp: Date.now(),
252
603
  };
253
604
  }
254
605
  catch (error) {
255
- throw new ProtocolError(`Failed to send payment: ${error.message}`, 'SPARK', 'SEND_PAYMENT_ERROR');
606
+ const msg = error instanceof Error ? error.message : String(error);
607
+ throw new ProtocolError(`Failed to send payment: ${msg}`, "SPARK", "SEND_PAYMENT_ERROR", error);
608
+ }
609
+ finally {
610
+ // Any send attempt (success OR failure) makes the cached balance stale;
611
+ // failures may still have produced a partial state change on the gateway.
612
+ invalidateSparkBalanceCache();
256
613
  }
257
614
  }
258
- async getPaymentStatus(paymentHash) {
259
- return {
260
- paymentHash,
261
- status: 'pending',
262
- };
615
+ async getPaymentStatus(paymentId) {
616
+ if (!this.isConnected()) {
617
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
618
+ }
619
+ try {
620
+ const wallet = sparkClientManager.getWallet();
621
+ // The Spark SDK may return entity IDs like "SparkLightningSendRequest:uuid"
622
+ // but getTransfer expects a plain UUID.
623
+ const transferId = paymentId.includes(":") ? paymentId.split(":").pop() : paymentId;
624
+ const transfer = (await wallet.getTransfer(transferId));
625
+ if (!transfer) {
626
+ throw new ProtocolError(`Payment not found: ${paymentId}`, "SPARK", "PAYMENT_STATUS_ERROR");
627
+ }
628
+ return {
629
+ paymentHash: paymentId,
630
+ status: mapTransferStatus(transfer.status),
631
+ amount: transfer.totalValue,
632
+ timestamp: transfer.createdTime?.getTime() ?? 0,
633
+ };
634
+ }
635
+ catch (error) {
636
+ if (error instanceof ProtocolError)
637
+ throw error;
638
+ const msg = error instanceof Error ? error.message : String(error);
639
+ throw new ProtocolError(`Failed to get payment status: ${msg}`, "SPARK", "PAYMENT_STATUS_ERROR", error);
640
+ }
263
641
  }
264
642
  // ========================================================================
265
643
  // Address Operations
266
644
  // ========================================================================
267
645
  async getReceiveAddress(assetId) {
268
646
  if (!this.isConnected()) {
269
- throw new ProtocolError('Not connected', 'SPARK', 'NOT_CONNECTED');
647
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
270
648
  }
271
649
  try {
272
650
  const wallet = sparkClientManager.getWallet();
273
- if (assetId === 'onchain' || assetId === 'btc_onchain') {
651
+ // Spark-to-Spark native address
652
+ if (assetId === "SPARK") {
653
+ const address = await wallet.getSparkAddress();
654
+ return {
655
+ address: address,
656
+ format: "SPARK_ADDRESS",
657
+ asset: "BTC",
658
+ };
659
+ }
660
+ // BTC on-chain deposit address
661
+ if (!assetId || assetId === "BTC" || assetId.toLowerCase() === "btc") {
274
662
  const address = await wallet.getSingleUseDepositAddress();
275
- return { address, format: 'BTC_ADDRESS', asset: 'BTC' };
663
+ return {
664
+ address,
665
+ format: "BTC_ADDRESS",
666
+ asset: "BTC",
667
+ };
276
668
  }
277
- const address = await wallet.getSparkAddress();
278
- return { address, format: 'SPARK_ADDRESS', asset: 'BTC' };
669
+ throw new ProtocolError("Spark only supports BTC", "SPARK", "UNSUPPORTED_ASSET");
279
670
  }
280
671
  catch (error) {
281
- throw new ProtocolError(`Failed to get address: ${error.message}`, 'SPARK', 'GET_ADDRESS_ERROR');
672
+ if (error instanceof ProtocolError)
673
+ throw error;
674
+ const msg = error instanceof Error ? error.message : String(error);
675
+ throw new ProtocolError(`Failed to get receive address: ${msg}`, "SPARK", "GET_ADDRESS_ERROR", error);
282
676
  }
283
677
  }
678
+ async claimSparkL1Deposit(params) {
679
+ if (!this.isConnected()) {
680
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
681
+ }
682
+ const address = params.address?.trim();
683
+ if (!address) {
684
+ return { status: "error", error: "address is required" };
685
+ }
686
+ const wallet = sparkClientManager.getWallet();
687
+ let utxos;
688
+ try {
689
+ utxos = await wallet.getUtxosForDepositAddress(address, 10, 0, true);
690
+ }
691
+ catch (error) {
692
+ return {
693
+ status: "error",
694
+ error: error instanceof Error ? error.message : "utxo lookup failed",
695
+ };
696
+ }
697
+ if (!utxos || utxos.length === 0)
698
+ return { status: "awaiting" };
699
+ const claimedTxids = [];
700
+ let lastError;
701
+ for (const utxo of utxos) {
702
+ try {
703
+ await wallet.claimDeposit(utxo.txid);
704
+ claimedTxids.push(utxo.txid);
705
+ }
706
+ catch (error) {
707
+ lastError = error instanceof Error ? error.message : String(error);
708
+ }
709
+ }
710
+ if (claimedTxids.length === 0) {
711
+ return { status: "error", error: lastError ?? "no utxos claimed" };
712
+ }
713
+ return { status: "claimed", txids: claimedTxids };
714
+ }
715
+ /**
716
+ * Sweep every previously-generated single-use deposit address that is still
717
+ * unclaimed and credit any confirmed UTXOs paid to them. Each call to
718
+ * `getSingleUseDepositAddress()` returns a *new* address, so a deposit sent
719
+ * to an address from a previous session would otherwise stay stranded:
720
+ * the deposit-screen poller only watches the address currently on screen.
721
+ * Run this on unlock (after SPARK connects) and when the user opens the
722
+ * deposit screen so stuck deposits surface as soon as possible.
723
+ */
724
+ async sweepSparkL1Deposits() {
725
+ if (!this.isConnected()) {
726
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
727
+ }
728
+ const wallet = sparkClientManager.getWallet();
729
+ let unused;
730
+ try {
731
+ unused = await wallet.getUnusedDepositAddresses();
732
+ }
733
+ catch (error) {
734
+ return {
735
+ addressesChecked: 0,
736
+ claimedTxids: [],
737
+ errors: [error instanceof Error ? error.message : "getUnusedDepositAddresses failed"],
738
+ };
739
+ }
740
+ if (!unused || unused.length === 0) {
741
+ return { addressesChecked: 0, claimedTxids: [], errors: [] };
742
+ }
743
+ const claimedTxids = [];
744
+ const errors = [];
745
+ for (const addr of unused) {
746
+ try {
747
+ const utxos = await wallet.getUtxosForDepositAddress(addr, 10, 0, true);
748
+ if (!utxos || utxos.length === 0)
749
+ continue;
750
+ for (const utxo of utxos) {
751
+ try {
752
+ await wallet.claimDeposit(utxo.txid);
753
+ claimedTxids.push(utxo.txid);
754
+ }
755
+ catch (claimErr) {
756
+ errors.push(claimErr instanceof Error ? claimErr.message : String(claimErr));
757
+ }
758
+ }
759
+ }
760
+ catch (lookupErr) {
761
+ errors.push(lookupErr instanceof Error ? lookupErr.message : String(lookupErr));
762
+ }
763
+ }
764
+ return { addressesChecked: unused.length, claimedTxids, errors };
765
+ }
284
766
  // ========================================================================
285
767
  // Node & Balance Operations
286
768
  // ========================================================================
287
769
  async getNodeInfo() {
288
770
  if (!this.isConnected()) {
289
- throw new ProtocolError('Not connected', 'SPARK', 'NOT_CONNECTED');
771
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
772
+ }
773
+ try {
774
+ const wallet = sparkClientManager.getWallet();
775
+ const { balance } = await getSparkBalanceCached(wallet);
776
+ const balanceSats = Number(balance);
777
+ return {
778
+ channelsBalanceMsat: balanceSats * 1000,
779
+ maxPayableMsat: balanceSats * 1000,
780
+ onchainBalanceMsat: 0,
781
+ pendingOnchainBalanceMsat: 0,
782
+ maxReceivableMsat: 0,
783
+ inboundLiquidityMsats: 0,
784
+ connectedPeers: [],
785
+ utxos: 0,
786
+ };
787
+ }
788
+ catch (error) {
789
+ const msg = error instanceof Error ? error.message : String(error);
790
+ throw new ProtocolError(`Failed to get node info: ${msg}`, "SPARK", "NODE_INFO_ERROR", error);
290
791
  }
291
- const wallet = sparkClientManager.getWallet();
292
- const { balance } = await wallet.getBalance();
293
- const balanceSats = Number(balance);
294
- return {
295
- channelsBalanceMsat: balanceSats * 1000,
296
- maxPayableMsat: balanceSats * 1000,
297
- onchainBalanceMsat: 0,
298
- maxReceivableMsat: 0,
299
- inboundLiquidityMsats: 0,
300
- connectedPeers: [],
301
- blockHeight: 0,
302
- pendingOnchainBalanceMsat: 0,
303
- utxos: 0,
304
- };
305
792
  }
306
793
  async getBtcBalance() {
307
794
  if (!this.isConnected()) {
308
- throw new ProtocolError('Not connected', 'SPARK', 'NOT_CONNECTED');
795
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
796
+ }
797
+ try {
798
+ const wallet = sparkClientManager.getWallet();
799
+ const { balance } = await getSparkBalanceCached(wallet);
800
+ const balanceSats = Number(balance);
801
+ return {
802
+ confirmed: balanceSats,
803
+ unconfirmed: 0,
804
+ total: balanceSats,
805
+ };
806
+ }
807
+ catch (error) {
808
+ const msg = error instanceof Error ? error.message : String(error);
809
+ throw new ProtocolError(`Failed to get BTC balance: ${msg}`, "SPARK", "BALANCE_ERROR", error);
309
810
  }
310
- const wallet = sparkClientManager.getWallet();
311
- const { balance } = await wallet.getBalance();
312
- const balanceSats = Number(balance);
313
- return { confirmed: balanceSats, unconfirmed: 0, total: balanceSats };
314
811
  }
315
812
  async listChannels() {
813
+ // Spark doesn't have traditional Lightning channels
316
814
  return [];
317
815
  }
318
816
  async listPayments() {
319
- const txs = await this.listTransactions();
320
- return { payments: txs };
817
+ if (!this.isConnected()) {
818
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
819
+ }
820
+ try {
821
+ const wallet = sparkClientManager.getWallet();
822
+ const { transfers } = (await withTimeout(wallet.getTransfers(), SPARK_RPC_TIMEOUT_MS, "spark.getTransfers"));
823
+ return { transfers: transfers };
824
+ }
825
+ catch (error) {
826
+ const msg = error instanceof Error ? error.message : String(error);
827
+ throw new ProtocolError(`Failed to list payments: ${msg}`, "SPARK", "LIST_PAYMENTS_ERROR", error);
828
+ }
321
829
  }
322
830
  async listTransfers(_options) {
831
+ // Spark doesn't have RGB-style transfers
323
832
  return { transfers: [] };
324
833
  }
834
+ async sendBtcOnchain(params) {
835
+ if (!this.isConnected()) {
836
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
837
+ }
838
+ try {
839
+ const wallet = sparkClientManager.getWallet();
840
+ // Step 1: Get fee quote — required by the SDK for cooperative exit
841
+ const feeQuote = await wallet.getWithdrawalFeeQuote({
842
+ amountSats: params.amount,
843
+ withdrawalAddress: params.address,
844
+ });
845
+ if (!feeQuote) {
846
+ throw new Error("Failed to get withdrawal fee quote");
847
+ }
848
+ // Step 2: Execute withdrawal with the fee quote
849
+ const feeAmountSats = (feeQuote.l1BroadcastFeeMedium?.originalValue ?? 0) +
850
+ (feeQuote.userFeeMedium?.originalValue ?? 0);
851
+ const result = await wallet.withdraw({
852
+ onchainAddress: params.address,
853
+ amountSats: params.amount,
854
+ exitSpeed: ExitSpeed.MEDIUM,
855
+ feeQuoteId: feeQuote.id,
856
+ feeAmountSats,
857
+ });
858
+ return result;
859
+ }
860
+ catch (error) {
861
+ const msg = error instanceof Error ? error.message : String(error);
862
+ throw new ProtocolError(`Failed to send BTC on-chain: ${msg}`, "SPARK", "SEND_BTC_ERROR", error);
863
+ }
864
+ finally {
865
+ invalidateSparkBalanceCache();
866
+ }
867
+ }
325
868
  // ========================================================================
326
- // Unsupported Operations
869
+ // PSBT Signing
327
870
  // ========================================================================
328
- supportsSwaps() {
329
- return false;
871
+ async signPsbt(psbtHex) {
872
+ if (!this.config?.mnemonic) {
873
+ throw new ProtocolError("Wallet mnemonic not available", "SPARK", "NOT_CONNECTED");
874
+ }
875
+ const { signPsbt: doSign } = await import("../lib/psbt-signer.js");
876
+ const result = doSign(psbtHex, this.config.mnemonic);
877
+ return { psbt: result.psbt, unchanged: result.unchanged };
330
878
  }
331
- async sendBtcOnchain(params) {
332
- if (!this.isConnected()) {
333
- throw new ProtocolError('Not connected', 'SPARK', 'NOT_CONNECTED');
879
+ // ========================================================================
880
+ // Message Signing
881
+ // ========================================================================
882
+ async signMessage(message) {
883
+ if (!this.config?.mnemonic) {
884
+ throw new ProtocolError("Wallet mnemonic not available", "SPARK", "NOT_CONNECTED");
334
885
  }
335
- const wallet = sparkClientManager.getWallet();
336
- const result = await wallet.withdraw({
337
- onchainAddress: params.address,
338
- amountSats: params.amount,
339
- exitSpeed: 'FAST',
340
- });
341
- return result;
886
+ const seed = mnemonicToSeedSync(this.config.mnemonic);
887
+ const root = HDKey.fromMasterSeed(seed);
888
+ // m/138'/1 — wallet-identity message-signing key, distinct from the
889
+ // LNURL-auth hashing key at m/138'/0.
890
+ const node = root.derive("m/138'/1");
891
+ if (!node.privateKey) {
892
+ throw new ProtocolError("Failed to derive message-signing key", "SPARK", "KEY_DERIVATION_ERROR");
893
+ }
894
+ return signLnMessage(message, node.privateKey);
895
+ }
896
+ async verifyMessage(message, signature) {
897
+ return verifyLnMessage(message, signature);
342
898
  }
343
899
  // ========================================================================
344
- // Private Helpers
900
+ // RGB-Specific Operations (Not supported by Spark)
345
901
  // ========================================================================
346
- convertTransfer(transfer) {
902
+ async createRgbInvoice(_params) {
903
+ throw new ProtocolError("RGB invoices not supported by Spark", "SPARK", "NOT_SUPPORTED");
904
+ }
905
+ async decodeRgbInvoice(_params) {
906
+ throw new ProtocolError("RGB invoice decoding not supported by Spark", "SPARK", "NOT_SUPPORTED");
907
+ }
908
+ async getInvoiceStatus(params) {
909
+ if (!this.isConnected()) {
910
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
911
+ }
912
+ const requestId = this.invoiceRequestIds.get(params.invoice);
913
+ if (!requestId) {
914
+ // Invoice not tracked — might be from a previous session
915
+ return { status: "Pending" };
916
+ }
347
917
  try {
348
- const isIncoming = transfer.transferDirection === 'INCOMING';
349
- const amountSats = Number(transfer.totalValue || 0);
350
- const timestamp = transfer.createdTime ? new Date(transfer.createdTime).getTime() : Date.now();
351
- const statusMap = {
352
- 'TRANSFER_STATUS_COMPLETED': 'confirmed',
353
- 'TRANSFER_STATUS_RETURNED': 'failed',
354
- 'TRANSFER_STATUS_EXPIRED': 'cancelled',
355
- };
356
- const btcAsset = {
357
- id: 'BTC',
358
- name: 'Bitcoin',
359
- ticker: 'BTC',
360
- precision: 8,
361
- protocol: 'SPARK',
362
- layer: 'SPARK_SPARK',
363
- balance: {
364
- total: amountSats,
365
- available: amountSats,
366
- pending: 0,
367
- totalDisplay: this.formatAmount(amountSats, 8),
368
- availableDisplay: this.formatAmount(amountSats, 8),
369
- },
370
- capabilities: {
371
- canSend: true, canReceive: true, canSwap: false,
372
- supportsLightning: true, supportsOnchain: true,
373
- },
374
- };
375
- return {
376
- id: transfer.id,
377
- type: isIncoming ? 'receive' : 'send',
378
- status: statusMap[transfer.status] || 'pending',
379
- timestamp,
380
- amount: amountSats,
381
- amountDisplay: this.formatAmount(amountSats, 8),
382
- fee: 0,
383
- asset: btcAsset,
384
- protocolData: { sparkTransfer: transfer },
385
- };
918
+ const wallet = sparkClientManager.getWallet();
919
+ const request = await wallet.getLightningReceiveRequest(requestId);
920
+ if (!request) {
921
+ return { status: "Pending" };
922
+ }
923
+ // Map LightningReceiveRequestStatus to simple status
924
+ const s = request.status;
925
+ if (s === "LIGHTNING_PAYMENT_RECEIVED" ||
926
+ s === "TRANSFER_COMPLETED" ||
927
+ s === "PAYMENT_PREIMAGE_RECOVERED") {
928
+ // Clean up tracked invoice on terminal state
929
+ this.invoiceRequestIds.delete(params.invoice);
930
+ return { status: "Succeeded" };
931
+ }
932
+ if (s === "TRANSFER_FAILED" ||
933
+ s === "TRANSFER_CREATION_FAILED" ||
934
+ s === "REFUND_SIGNING_COMMITMENTS_QUERYING_FAILED" ||
935
+ s === "REFUND_SIGNING_FAILED" ||
936
+ s === "PAYMENT_PREIMAGE_RECOVERING_FAILED") {
937
+ this.invoiceRequestIds.delete(params.invoice);
938
+ return { status: "Failed" };
939
+ }
940
+ // INVOICE_CREATED, TRANSFER_CREATED, etc.
941
+ return { status: "Pending" };
942
+ }
943
+ catch (error) {
944
+ const msg = error instanceof Error ? error.message : String(error);
945
+ log.warn("[SparkAdapter] Invoice status check failed:", msg);
946
+ return { status: "Pending" };
386
947
  }
387
- catch {
388
- return null;
948
+ }
949
+ async sendAsset(params) {
950
+ if (!this.isConnected()) {
951
+ throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
389
952
  }
953
+ try {
954
+ const wallet = sparkClientManager.getWallet();
955
+ const senderSparkAddress = (await wallet.getSparkAddress());
956
+ const assignmentAmount = params.assignment?.value;
957
+ const tokenAmount = typeof assignmentAmount === "number" && assignmentAmount > 0
958
+ ? assignmentAmount
959
+ : params.amount;
960
+ if (!Number.isFinite(tokenAmount) || tokenAmount <= 0) {
961
+ throw new Error("Spark token amount must be greater than 0");
962
+ }
963
+ const destination = params.recipientId.trim();
964
+ // Resolve token metadata for the sent-record (cached balance is warm from the send UI)
965
+ let sentMeta = { ticker: "TOKEN", name: params.assetId, decimals: 0 };
966
+ try {
967
+ const { tokenBalances } = await getSparkBalanceCached(wallet);
968
+ const info = tokenBalances?.get(params.assetId);
969
+ if (info) {
970
+ sentMeta = {
971
+ ticker: info.tokenMetadata.tokenTicker,
972
+ name: info.tokenMetadata.tokenName,
973
+ decimals: info.tokenMetadata.decimals,
974
+ };
975
+ }
976
+ }
977
+ catch {
978
+ // Non-critical — falls back to tokenMetaMap in listTransactions
979
+ }
980
+ // Check if the destination is a Spark invoice (contains sparkInvoiceFields)
981
+ if (isValidSparkAddress(destination)) {
982
+ const network = getNetworkFromSparkAddress(destination);
983
+ const decoded = decodeSparkAddress(destination, network);
984
+ if (decoded.sparkInvoiceFields) {
985
+ // Spark token invoice — use fulfillSparkInvoice
986
+ const response = await wallet.fulfillSparkInvoice([
987
+ {
988
+ invoice: destination,
989
+ amount: BigInt(tokenAmount),
990
+ },
991
+ ]);
992
+ if (response.tokenTransactionErrors.length > 0) {
993
+ throw new Error(response.tokenTransactionErrors[0].error.message);
994
+ }
995
+ if (response.invalidInvoices.length > 0) {
996
+ throw new Error(response.invalidInvoices[0].error.message);
997
+ }
998
+ const success = response.tokenTransactionSuccess[0];
999
+ if (success) {
1000
+ await saveSentTokenRecord({
1001
+ hash: success.txid,
1002
+ senderSparkAddress,
1003
+ amount: tokenAmount,
1004
+ assetId: params.assetId,
1005
+ ...sentMeta,
1006
+ timestamp: Date.now(),
1007
+ });
1008
+ return { txId: success.txid };
1009
+ }
1010
+ // Fallback: maybe it was a sats invoice bundled with token
1011
+ const satsSuccess = response.satsTransactionSuccess[0];
1012
+ if (satsSuccess) {
1013
+ return { txId: satsSuccess.transferResponse.id };
1014
+ }
1015
+ throw new Error("Spark invoice payment returned no result");
1016
+ }
1017
+ }
1018
+ // Plain Spark address — use transferTokens
1019
+ const txId = await wallet.transferTokens({
1020
+ tokenIdentifier: params.assetId,
1021
+ tokenAmount: BigInt(tokenAmount),
1022
+ receiverSparkAddress: destination,
1023
+ });
1024
+ await saveSentTokenRecord({
1025
+ hash: txId,
1026
+ senderSparkAddress,
1027
+ amount: tokenAmount,
1028
+ assetId: params.assetId,
1029
+ ...sentMeta,
1030
+ timestamp: Date.now(),
1031
+ });
1032
+ return { txId };
1033
+ }
1034
+ catch (error) {
1035
+ const msg = error instanceof Error ? error.message : String(error);
1036
+ throw new ProtocolError(`Failed to send Spark token: ${msg}`, "SPARK", "SEND_ASSET_ERROR", error);
1037
+ }
1038
+ }
1039
+ // ========================================================================
1040
+ // Swap Operations (Not supported by Spark)
1041
+ // ========================================================================
1042
+ supportsSwaps() {
1043
+ return false;
1044
+ }
1045
+ async getSwapQuote(_request) {
1046
+ throw new ProtocolError("Swap operations not supported by Spark", "SPARK", "NOT_SUPPORTED");
1047
+ }
1048
+ async executeSwap(_quote) {
1049
+ throw new ProtocolError("Swap operations not supported by Spark", "SPARK", "NOT_SUPPORTED");
390
1050
  }
391
- formatAmount(amount, precision) {
392
- return (amount / Math.pow(10, precision)).toFixed(precision);
1051
+ async getSwapStatus(_swapId) {
1052
+ throw new ProtocolError("Swap operations not supported by Spark", "SPARK", "NOT_SUPPORTED");
393
1053
  }
394
1054
  }
395
1055
  //# sourceMappingURL=SparkAdapter.js.map