@kaleidorg/wallet-engine 1.0.0-beta.38 → 1.0.0-beta.41

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.
@@ -1,26 +1,22 @@
1
1
  /**
2
2
  * SparkWdkAdapter
3
3
  * ---------------
4
- * Thin adapter mapping the WDK Spark module (@tetherto/wdk-wallet-spark) onto the
4
+ * Adapter mapping the WDK Spark module (@tetherto/wdk-wallet-spark) onto the
5
5
  * stable `IProtocolAdapter` contract. This is the reference implementation of the
6
6
  * "wrap a WDK module behind the contract" pattern (see docs/WDK_INTEGRATION_PLAN.md).
7
7
  *
8
8
  * Discipline rules enforced here:
9
9
  * - NO WDK/SDK types cross the contract boundary — everything returned is a domain
10
10
  * type from ../types/base. The WDK objects are held as `any` internally.
11
- * - Protocol quirks (zero-fee, static address) live in the capability manifest,
12
- * not in this interface.
13
- *
14
- * WDK Spark account surface (captured via Spike A, 2026-06-03):
15
- * manager: getAccount, getAccountByPath, getFeeRates
16
- * account: getAddress, getBalance, sendTransaction, transfer,
17
- * getStaticDepositAddress, getSingleUseDepositAddress, quoteWithdraw,
18
- * withdraw, createLightningInvoice, payLightningInvoice,
19
- * createSparkSatsInvoice, createSparkTokensInvoice, paySparkInvoice,
20
- * syncWalletBalance, dispose, cleanupConnections
21
- *
22
- * Status: skeleton — core receive/balance/invoice/send wired to the real WDK calls;
23
- * remaining contract methods stubbed with explicit ProtocolError until Phase 2.
11
+ * - The WDK **account** surface is the primary path (getAddress, getBalance,
12
+ * payLightningInvoice, sendTransaction, getTransfers, createLightningInvoice, …).
13
+ * - The raw `SparkWallet` the account wraps (`account._wallet`) is reached ONLY for
14
+ * the rich paths the WDK surface does not expose directly — token send + outbox,
15
+ * token history, L1 deposit claiming, and on-chain (cooperative-exit) withdrawal —
16
+ * ported verbatim from the mature native SparkAdapter (identical behaviour).
17
+ * - The sub-path stays free of a *static* `@buildonspark/spark-sdk` import: the SDK
18
+ * address helpers are lazy-loaded in `connect()`, and the one SDK-coupled lib
19
+ * (spark-converters, used for token-history mapping) is dynamic-imported on demand.
24
20
  */
25
21
  import { ProtocolError, } from '../../types/base.js';
26
22
  import { getCapabilities } from '../../capabilities/index.js';
@@ -28,6 +24,13 @@ import { PROTOCOL_OPERATIONS } from '../../capabilities/operations.js';
28
24
  import { loadWdkModule } from './moduleLoader.js';
29
25
  import { decodeBolt11, isBolt11 } from '../../lib/bolt11.js';
30
26
  import { BaseWdkAdapter } from './BaseWdkAdapter.js';
27
+ import { formatAmount, mapTransferStatus, parseSdkExpiryMs, rawTokenIdFromBech32mTokenId, rawTokenIdFromBytes, tokenRefsMatch, txHashFromBytes, } from '../../lib/spark-helpers.js';
28
+ import { getSparkBalanceCached, invalidateSparkBalanceCache } from '../../lib/spark-balance-cache.js';
29
+ import { loadSentTokenRecords, normalizeTxHash, saveSentTokenRecord, } from '../../lib/spark-sent-token-records.js';
30
+ import { signLnMessage, verifyLnMessage } from '../../lib/ln-message-sign.js';
31
+ import { resolveWalletSeed } from '../../lib/wallet-seed.js';
32
+ /** Default maximum fee for Lightning payments (sats) — mirrors the native adapter. */
33
+ const DEFAULT_MAX_FEE_SATS = 1000;
31
34
  /** Lower-case hex string for a Uint8Array / Buffer / hex string (for identity-key compare). */
32
35
  function toHexLower(bytes) {
33
36
  if (!bytes)
@@ -75,6 +78,19 @@ export class SparkWdkAdapter extends BaseWdkAdapter {
75
78
  // since the spark-sdk Transfer proto exposes sender/receiver identity keys
76
79
  // rather than an explicit direction flag.
77
80
  this.identityPubKeyHex = null;
81
+ /** BIP-39 mnemonic — retained for message/PSBT signing (derives its own keys). */
82
+ this.mnemonic = null;
83
+ /** Lazily-loaded `@buildonspark/spark-sdk` address helpers (kept off the static import graph). */
84
+ this.sdk = null;
85
+ /** Maps a created Lightning invoice string → its receive-request id (for status polling). */
86
+ this.invoiceRequestIds = new Map();
87
+ }
88
+ /** The raw SparkWallet the WDK account wraps — proven surface for token/deposit/withdrawal ops. */
89
+ get rawWallet() {
90
+ const w = this.account?._wallet;
91
+ if (!w)
92
+ throw new ProtocolError('Spark wallet unavailable', 'SPARK', 'NOT_CONNECTED');
93
+ return w;
78
94
  }
79
95
  // --- Connection ---------------------------------------------------------
80
96
  async connect(config) {
@@ -82,12 +98,15 @@ export class SparkWdkAdapter extends BaseWdkAdapter {
82
98
  if (!cfg.mnemonic) {
83
99
  throw new ProtocolError('SparkWdkAdapter requires a mnemonic', 'SPARK', 'CONFIG');
84
100
  }
101
+ this.mnemonic = cfg.mnemonic;
85
102
  this.network = cfg.network ?? 'mainnet';
86
103
  // Injectable loader (RN injects a static require; Node/Vite use the import fallback).
87
104
  // @ts-ignore — declared as a workspace/optional dep; resolved at runtime.
88
105
  const mod = await loadWdkModule('@tetherto/wdk-wallet-spark', () => import('@tetherto/wdk-wallet-spark'));
89
106
  const WalletManagerSpark = mod.default ?? mod;
90
- this.manager = new WalletManagerSpark(cfg.mnemonic, {
107
+ // Resolve to seed bytes so nsec/hex-rooted wallets bypass the WDK base's
108
+ // BIP-39 string validation (which throws "The seed phrase is invalid").
109
+ this.manager = new WalletManagerSpark(resolveWalletSeed(cfg.mnemonic), {
91
110
  network: SPARK_NETWORK_MAP[this.network] ?? 'MAINNET',
92
111
  });
93
112
  this.account = await this.manager.getAccount(cfg.accountIndex ?? 0);
@@ -97,45 +116,82 @@ export class SparkWdkAdapter extends BaseWdkAdapter {
97
116
  catch {
98
117
  this.identityPubKeyHex = null;
99
118
  }
119
+ // Lazy-load the SDK address helpers used to classify send destinations. Kept
120
+ // out of the static import graph so this sub-path stays SDK-free until used.
121
+ // @ts-ignore — resolved at runtime; a transitive dep of the WDK Spark module.
122
+ this.sdk = await loadWdkModule('@buildonspark/spark-sdk', () => import('@buildonspark/spark-sdk'));
123
+ // Back the native sparkClientManager singleton with this adapter's underlying
124
+ // SparkWallet, so host glue that reads Spark through it (Flashnet AMM, the
125
+ // Orchestra bridge) keeps working under the WDK backend — no second wallet, no
126
+ // derivation drift. Lazy-imported so spark-client-manager (which statically
127
+ // imports spark-sdk) never enters this sub-path's static graph.
128
+ try {
129
+ const { sparkClientManager } = await import('../../lib/spark-client-manager.js');
130
+ sparkClientManager.adoptExternalWallet(this.account?._wallet, this.network);
131
+ }
132
+ catch {
133
+ /* flashnet/bridge glue is optional — never block connect on it */
134
+ }
100
135
  this.connected = true;
101
136
  }
102
137
  async getConnectionInfo() {
103
- return { protocol: 'SPARK', connected: this.connected, network: this.network };
138
+ this.assertConnected();
139
+ // Warm the balance cache so the dashboard's first read is coalesced.
140
+ await getSparkBalanceCached(this.rawWallet).catch(() => { });
141
+ return {
142
+ protocol: 'SPARK',
143
+ connected: this.connected,
144
+ network: this.network,
145
+ syncStatus: { synced: true, progress: 100 },
146
+ };
104
147
  }
105
148
  // --- Address / receive --------------------------------------------------
106
- async getReceiveAddress() {
149
+ async getReceiveAddress(assetId) {
107
150
  this.assertConnected();
108
- const address = await this.account.getAddress();
109
- return { address, format: 'SPARK_ADDRESS' };
151
+ // Spark-to-Spark native address.
152
+ if (assetId === 'SPARK') {
153
+ const address = await this.account.getAddress();
154
+ return { address, format: 'SPARK_ADDRESS', asset: 'BTC' };
155
+ }
156
+ // BTC on-chain deposit address (default).
157
+ if (!assetId || assetId.toLowerCase() === 'btc') {
158
+ const address = await this.account.getSingleUseDepositAddress();
159
+ return { address, format: 'BTC_ADDRESS', asset: 'BTC' };
160
+ }
161
+ throw new ProtocolError('Spark only supports BTC', 'SPARK', 'UNSUPPORTED_ASSET');
110
162
  }
111
163
  // --- Balance ------------------------------------------------------------
112
164
  async getBtcBalance() {
113
165
  this.assertConnected();
114
- // WDK: getBalance(): Promise<bigint> sats, settled balance.
115
- const bal = await this.account.getBalance();
116
- const total = Number(bal);
166
+ const { balance } = await getSparkBalanceCached(this.rawWallet);
167
+ const total = Number(balance);
117
168
  return { confirmed: total, unconfirmed: 0, total };
118
169
  }
119
170
  async refreshBalances() {
120
171
  this.assertConnected();
121
- await this.account.syncWalletBalance?.();
172
+ // Drop the short-TTL coalescing cache so the next read hits the gateway,
173
+ // then reconcile server-side state (best-effort).
174
+ invalidateSparkBalanceCache();
175
+ await this.account.syncWalletBalance?.().catch(() => { });
122
176
  }
123
177
  async listAssets() {
124
178
  this.assertConnected();
125
- const { total } = await this.getBtcBalance();
179
+ const { balance, tokenBalances } = await getSparkBalanceCached(this.rawWallet);
180
+ const balanceSats = Number(balance);
126
181
  const btc = {
127
182
  id: 'BTC',
128
183
  name: 'Bitcoin',
129
184
  ticker: 'BTC',
130
185
  precision: 8,
131
186
  protocol: 'SPARK',
132
- layer: 'BTC_SPARK',
187
+ layer: 'SPARK_SPARK',
133
188
  balance: {
134
- total,
135
- available: total,
189
+ total: balanceSats,
190
+ available: balanceSats,
136
191
  pending: 0,
137
- totalDisplay: String(total),
138
- availableDisplay: String(total),
192
+ locked: 0,
193
+ totalDisplay: formatAmount(balanceSats, 8),
194
+ availableDisplay: formatAmount(balanceSats, 8),
139
195
  },
140
196
  capabilities: {
141
197
  canSend: true,
@@ -145,90 +201,73 @@ export class SparkWdkAdapter extends BaseWdkAdapter {
145
201
  supportsOnchain: true,
146
202
  },
147
203
  };
148
- const out = [btc];
149
- // Spark tokens. The WDK account wraps a SparkWallet whose `getBalance()`
150
- // returns `{ satsBalance, tokenBalances }`, where tokenBalances is a
151
- // Map<bech32m identifier, { balance, tokenMetadata|tokenInfo }>. Token
152
- // enumeration is best-effort: never let it break the BTC listing.
153
- try {
154
- const wallet = this.account?._wallet;
155
- const full = wallet?.getBalance ? await wallet.getBalance() : null;
156
- const tokenBalances = full?.tokenBalances;
157
- if (tokenBalances && typeof tokenBalances.forEach === 'function') {
158
- // TokenBalanceMap value = { ownedBalance, availableToSendBalance, tokenMetadata }
159
- // (UserTokenMetadata: tokenName/tokenTicker/decimals). There is NO `balance`
160
- // field — reading it returned 0. The Map key is the bech32m token identifier.
161
- tokenBalances.forEach((entry, key) => {
162
- const meta = entry?.tokenMetadata ?? entry?.tokenInfo ?? {};
163
- const id = String(key);
164
- if (!id)
165
- return;
166
- const owned = Number(entry?.ownedBalance ?? entry?.balance ?? 0);
167
- const available = Number(entry?.availableToSendBalance ?? entry?.ownedBalance ?? 0);
168
- const decimals = Number(meta.decimals ?? meta.tokenDecimals ?? 0);
169
- const ticker = meta.tokenTicker ?? meta.tokenSymbol ?? meta.symbol ?? id.slice(0, 6);
170
- out.push({
171
- id,
172
- name: meta.tokenName ?? meta.name ?? ticker,
173
- ticker,
174
- precision: decimals,
175
- protocol: 'SPARK',
176
- layer: 'SPARK_SPARK',
177
- balance: {
178
- total: owned,
179
- available,
180
- pending: 0,
181
- totalDisplay: String(owned),
182
- availableDisplay: String(available),
183
- },
184
- capabilities: {
185
- canSend: true,
186
- canReceive: true,
187
- canSwap: true,
188
- supportsLightning: false,
189
- supportsOnchain: false,
190
- },
191
- });
204
+ const assets = [btc];
205
+ if (tokenBalances && tokenBalances.size > 0) {
206
+ for (const [tokenId, info] of tokenBalances) {
207
+ const meta = info.tokenMetadata;
208
+ const owned = Number(info.ownedBalance);
209
+ const available = Number(info.availableToSendBalance);
210
+ const precision = meta.decimals ?? 8;
211
+ assets.push({
212
+ id: tokenId,
213
+ name: meta.tokenName,
214
+ ticker: meta.tokenTicker,
215
+ icon: meta.tokenImageUrl,
216
+ precision,
217
+ protocol: 'SPARK',
218
+ layer: 'SPARK_SPARK',
219
+ balance: {
220
+ total: owned,
221
+ available,
222
+ pending: 0,
223
+ locked: owned - available,
224
+ totalDisplay: formatAmount(owned, precision),
225
+ availableDisplay: formatAmount(available, precision),
226
+ },
227
+ capabilities: {
228
+ canSend: true,
229
+ canReceive: true,
230
+ canSwap: false,
231
+ supportsLightning: false,
232
+ supportsOnchain: false,
233
+ },
192
234
  });
193
235
  }
194
236
  }
195
- catch {
196
- // tokens are best-effort — keep BTC even if the token map is unavailable
197
- }
198
- return out;
199
- }
200
- async getAssetBalance(assetId) {
201
- const assets = await this.listAssets();
202
- const found = assets.find((a) => a.id === assetId);
203
- if (!found)
204
- throw new ProtocolError(`Unknown asset ${assetId}`, 'SPARK', 'NO_ASSET');
205
- return found.balance;
237
+ return assets;
206
238
  }
207
239
  async getAsset(assetId) {
208
240
  const assets = await this.listAssets();
209
- const found = assets.find((a) => a.id === assetId);
241
+ const found = assets.find((a) => a.id === assetId || a.ticker === assetId);
210
242
  if (!found)
211
- throw new ProtocolError(`Unknown asset ${assetId}`, 'SPARK', 'NO_ASSET');
243
+ throw new ProtocolError(`Asset not found: ${assetId}`, 'SPARK', 'ASSET_NOT_FOUND');
212
244
  return found;
213
245
  }
246
+ async getAssetBalance(assetId) {
247
+ const found = await this.getAsset(assetId);
248
+ return found.balance;
249
+ }
214
250
  // --- Invoices / receive amounts ----------------------------------------
215
251
  async createInvoice(request) {
216
252
  this.assertConnected();
217
253
  const expiresAt = Date.now() + (request.expirySeconds ?? 3600) * 1000;
218
254
  // 1) Lightning receive (BOLT11) — when the caller targets the LN layer.
219
255
  if (request.layer === 'BTC_LN') {
220
- // WDK createLightningInvoice({ amountSats, memo, expirySeconds }): LightningReceiveRequest
221
256
  const r = await this.account.createLightningInvoice({
222
257
  amountSats: request.amount ?? 0,
223
258
  memo: request.description,
224
259
  expirySeconds: request.expirySeconds,
225
260
  });
226
- const encoded = r?.invoice?.encodedInvoice ?? r?.encodedInvoice ?? r?.invoice ?? '';
261
+ const inv = r?.invoice ?? {};
262
+ const encoded = inv?.encodedInvoice ?? r?.encodedInvoice ?? r?.invoice ?? '';
263
+ // Track the receive-request id so getInvoiceStatus can poll it later.
264
+ if (r?.id && encoded)
265
+ this.invoiceRequestIds.set(encoded, r.id);
227
266
  return {
228
267
  invoice: encoded,
229
- paymentHash: r?.invoice?.paymentHash ?? r?.id ?? '',
268
+ paymentHash: inv?.paymentHash ?? r?.id ?? '',
230
269
  amount: request.amount,
231
- expiresAt,
270
+ expiresAt: parseSdkExpiryMs(inv?.expiryTime ?? inv?.expiresAt) ?? expiresAt,
232
271
  description: request.description,
233
272
  };
234
273
  }
@@ -243,65 +282,27 @@ export class SparkWdkAdapter extends BaseWdkAdapter {
243
282
  }
244
283
  // 3) Default: native Spark sats invoice — returns a SparkAddressFormat string.
245
284
  const invoice = await this.account.createSparkSatsInvoice({
246
- amount: request.amount,
285
+ amount: request.amount || undefined,
247
286
  memo: request.description,
248
287
  });
249
288
  return { invoice, paymentHash: '', amount: request.amount, expiresAt, description: request.description };
250
289
  }
251
- // --- Send ---------------------------------------------------------------
252
- async sendPayment(request) {
290
+ /** Optional: explicit native Spark sats invoice (used by the receive UI). */
291
+ async createSparkInvoice(request) {
253
292
  this.assertConnected();
254
- const dest = request.invoice.trim();
255
- const isBolt11 = /^ln(bc|tb|bcrt)/i.test(dest);
256
- const timestamp = Date.now();
257
- // 1) Lightning send WDK requires a maxFeeSats cap.
258
- if (isBolt11) {
259
- const r = await this.account.payLightningInvoice({
260
- invoice: dest,
261
- maxFeeSats: request.maxFeeSats ?? this.defaultMaxFeeSats(request.amount),
262
- // Required by the underlying Spark SDK for 0-amount (amountless) invoices;
263
- // for invoices that already carry an amount this must be omitted. The WDK
264
- // module forwards these options verbatim to spark-sdk's payLightningInvoice.
265
- ...(request.amount && request.amount > 0 ? { amountSatsToSend: request.amount } : {}),
266
- });
267
- return {
268
- paymentHash: r?.paymentHash ?? r?.id ?? '',
269
- preimage: r?.preimage,
270
- amount: Number(r?.amountSats ?? request.amount ?? 0),
271
- fee: Number(r?.feeSats ?? 0),
272
- status: 'confirmed',
273
- timestamp,
274
- };
275
- }
276
- // 2) Plain Spark address + explicit amount → direct transfer (zero-fee).
277
- if (request.amount != null) {
278
- const r = await this.account.sendTransaction({ to: dest, value: request.amount });
279
- return {
280
- paymentHash: r?.id ?? r?.transferId ?? '',
281
- amount: request.amount,
282
- fee: 0, // Spark transfers are zero-fee (capability flag)
283
- status: 'confirmed',
284
- timestamp,
285
- };
286
- }
287
- // 3) Encoded Spark invoice (amount embedded) → fulfill. Takes an ARRAY.
288
- const res = await this.account.paySparkInvoice([{ invoice: dest }]);
289
- const ok = res?.satsTransactionSuccess?.[0];
293
+ const invoice = await this.account.createSparkSatsInvoice({
294
+ amount: request.amount || undefined,
295
+ memo: request.description,
296
+ expiryTime: request.expirySeconds ? new Date(Date.now() + request.expirySeconds * 1000) : undefined,
297
+ });
290
298
  return {
291
- paymentHash: ok?.transferResponse?.id ?? '',
292
- amount: Number(request.amount ?? 0),
293
- fee: 0,
294
- status: ok ? 'confirmed' : 'failed',
295
- timestamp,
299
+ invoice,
300
+ paymentHash: '',
301
+ amount: request.amount,
302
+ expiresAt: Date.now() + (request.expirySeconds ?? 3600) * 1000,
303
+ description: request.description,
296
304
  };
297
305
  }
298
- /** Conservative default LN fee cap: 0.5% of amount, min 5 sats. */
299
- defaultMaxFeeSats(amount) {
300
- if (!amount || amount <= 0)
301
- return 10;
302
- return Math.max(5, Math.ceil(amount * 0.005));
303
- }
304
- // --- Transactions -------------------------------------------------------
305
306
  async decodeInvoice(invoice) {
306
307
  const dest = invoice.trim();
307
308
  if (isBolt11(dest)) {
@@ -311,46 +312,253 @@ export class SparkWdkAdapter extends BaseWdkAdapter {
311
312
  // Spark invoice/address — no on-device decode; surface the raw value.
312
313
  return { paymentHash: '', expiresAt: 0, destination: dest };
313
314
  }
314
- async getPaymentStatus(paymentHash) {
315
+ // --- Send ---------------------------------------------------------------
316
+ async sendPayment(request) {
317
+ this.assertConnected();
318
+ const destination = request.invoice.trim();
319
+ const timestamp = Date.now();
320
+ try {
321
+ // 1) Lightning send (WDK account). Settles atomically — a clean return
322
+ // means dispatched; its id is not queryable via getTransfer, so we
323
+ // treat a non-failed return as confirmed.
324
+ if (isBolt11(destination)) {
325
+ const maxFee = request.maxFeeSats ?? request.maxFee ?? DEFAULT_MAX_FEE_SATS;
326
+ const result = await this.account.payLightningInvoice({
327
+ invoice: destination,
328
+ maxFeeSats: maxFee,
329
+ // Amountless (0-sat) invoices require an explicit amount; omit otherwise.
330
+ ...(request.amount && request.amount > 0 ? { amountSatsToSend: request.amount } : {}),
331
+ });
332
+ const raw = mapTransferStatus(result?.status);
333
+ return {
334
+ paymentHash: String(result?.paymentHash ?? result?.id ?? ''),
335
+ amount: Number(result?.amountSats ?? result?.totalValue ?? request.amount ?? 0),
336
+ fee: Number(result?.feeSats ?? 0),
337
+ status: raw === 'failed' ? 'failed' : 'confirmed',
338
+ timestamp: result?.createdTime instanceof Date ? result.createdTime.getTime() : timestamp,
339
+ };
340
+ }
341
+ // 2) Spark address or Spark invoice (WDK account).
342
+ if (this.sdk?.isValidSparkAddress?.(destination)) {
343
+ const network = this.sdk.getNetworkFromSparkAddress(destination);
344
+ const decoded = this.sdk.decodeSparkAddress(destination, network);
345
+ if (decoded.sparkInvoiceFields) {
346
+ const response = await this.account.paySparkInvoice([
347
+ { invoice: destination, amount: request.amount ? BigInt(request.amount) : undefined },
348
+ ]);
349
+ if (response.satsTransactionErrors?.length > 0) {
350
+ throw new Error(response.satsTransactionErrors[0].error.message);
351
+ }
352
+ const success = response.satsTransactionSuccess?.[0];
353
+ if (!success)
354
+ throw new Error('Spark invoice payment returned no result');
355
+ const transfer = success.transferResponse;
356
+ return {
357
+ paymentHash: transfer.id,
358
+ amount: Number(transfer.totalValue ?? 0),
359
+ fee: 0,
360
+ status: mapTransferStatus(transfer.status),
361
+ timestamp: transfer.createdTime?.getTime() ?? timestamp,
362
+ };
363
+ }
364
+ // Plain Spark address — zero-fee direct transfer.
365
+ const transfer = await this.account.sendTransaction({ to: destination, value: request.amount ?? 0 });
366
+ return {
367
+ paymentHash: transfer?.id ?? transfer?.transferId ?? '',
368
+ amount: Number(transfer?.totalValue ?? request.amount ?? 0),
369
+ fee: 0, // Spark transfers are zero-fee (capability flag)
370
+ status: transfer?.status ? mapTransferStatus(transfer.status) : 'confirmed',
371
+ timestamp: transfer?.createdTime?.getTime?.() ?? timestamp,
372
+ };
373
+ }
374
+ // 3) On-chain BTC withdrawal (cooperative exit) via the raw wallet — the
375
+ // WDK withdraw option shape differs; use the proven native path.
376
+ const wallet = this.rawWallet;
377
+ const feeQuote = await wallet.getWithdrawalFeeQuote({
378
+ amountSats: request.amount ?? 0,
379
+ withdrawalAddress: destination,
380
+ });
381
+ if (!feeQuote)
382
+ throw new Error('Failed to get withdrawal fee quote for on-chain exit');
383
+ const feeAmountSats = (feeQuote.l1BroadcastFeeMedium?.originalValue ?? 0) + (feeQuote.userFeeMedium?.originalValue ?? 0);
384
+ const result = await wallet.withdraw({
385
+ onchainAddress: destination,
386
+ amountSats: request.amount ?? 0,
387
+ exitSpeed: this.sdk?.ExitSpeed?.MEDIUM ?? 'MEDIUM',
388
+ feeQuoteId: feeQuote.id,
389
+ feeAmountSats,
390
+ });
391
+ return {
392
+ paymentHash: result?.id ?? '',
393
+ amount: request.amount ?? 0,
394
+ fee: result?.fee?.originalValue ?? 0,
395
+ status: 'pending',
396
+ timestamp,
397
+ };
398
+ }
399
+ finally {
400
+ // Any send attempt (success OR failure) makes the cached balance stale.
401
+ invalidateSparkBalanceCache();
402
+ }
403
+ }
404
+ async getPaymentStatus(paymentId) {
315
405
  this.assertConnected();
316
- const t = await this.account.getTransactionReceipt(paymentHash).catch(() => null);
317
- return { paymentHash, status: mapSparkStatus(t?.status), amount: t ? Number(t.totalValue ?? 0) : undefined };
406
+ // Spark may return entity ids like "SparkLightningSendRequest:uuid"; getTransactionReceipt wants the uuid.
407
+ const id = paymentId.includes(':') ? paymentId.split(':').pop() : paymentId;
408
+ const t = await this.account.getTransactionReceipt(id).catch(() => null);
409
+ if (!t)
410
+ return { paymentHash: paymentId, status: 'pending' };
411
+ return {
412
+ paymentHash: paymentId,
413
+ status: mapSparkStatus(t.status),
414
+ amount: Number(t.totalValue ?? 0),
415
+ timestamp: t.createdTime?.getTime?.() ?? 0,
416
+ };
318
417
  }
418
+ // --- Transactions -------------------------------------------------------
319
419
  async listTransactions(filter) {
320
420
  this.assertConnected();
321
- const transfers = await this.account.getTransfers({ limit: filter?.limit ?? 50, skip: filter?.offset ?? 0 });
322
- return (transfers ?? []).map((t) => this.toUnifiedTx(t));
421
+ const limit = filter?.limit ?? 20;
422
+ const offset = filter?.offset ?? 0;
423
+ const requestedAsset = filter?.asset?.trim();
424
+ const shouldFetchBtc = !requestedAsset || requestedAsset === 'BTC';
425
+ const shouldFetchTokens = !requestedAsset || requestedAsset !== 'BTC';
426
+ // BTC transfers via the WDK account — best effort; a failure here must not
427
+ // hide token activity (and especially not the offline send-record fallback).
428
+ let btcTxs = [];
429
+ if (shouldFetchBtc) {
430
+ try {
431
+ const transfers = await this.account.getTransfers({ limit, skip: offset });
432
+ btcTxs = (transfers ?? []).map((t) => this.toUnifiedTx(t));
433
+ }
434
+ catch {
435
+ /* isolated */
436
+ }
437
+ }
438
+ // Token transactions via the raw wallet — every RPC below is best-effort and
439
+ // isolated so a transport failure never hides locally-recorded sends (the only
440
+ // reliable record of an outgoing token transfer with no change output).
441
+ const tokenTxs = [];
442
+ if (shouldFetchTokens) {
443
+ try {
444
+ const wallet = this.rawWallet;
445
+ const requestedTokenRawId = requestedAsset && requestedAsset !== 'BTC' ? rawTokenIdFromBech32mTokenId(requestedAsset) : '';
446
+ // spark-converters statically imports the SDK — dynamic-import so this
447
+ // sub-path stays SDK-free until token history is actually requested.
448
+ const { convertTokenTransactionToUnified, buildSentRecordTransaction } = await import('../../lib/spark-converters.js');
449
+ const sparkAddress = await wallet.getSparkAddress();
450
+ const identityPubKey = await wallet.getIdentityPublicKey();
451
+ let networkType = '';
452
+ try {
453
+ networkType = this.sdk.getNetworkFromSparkAddress(sparkAddress);
454
+ }
455
+ catch {
456
+ /* non-fatal */
457
+ }
458
+ const tokenMetaMap = new Map();
459
+ const rawTokenMetaMap = new Map();
460
+ try {
461
+ const { tokenBalances } = await wallet.getBalance();
462
+ if (tokenBalances) {
463
+ for (const [tokenId, info] of tokenBalances) {
464
+ const meta = {
465
+ name: info.tokenMetadata.tokenName,
466
+ ticker: info.tokenMetadata.tokenTicker,
467
+ decimals: info.tokenMetadata.decimals,
468
+ };
469
+ tokenMetaMap.set(tokenId, meta);
470
+ const rawTokenId = rawTokenIdFromBytes(info.tokenMetadata.rawTokenIdentifier);
471
+ if (rawTokenId)
472
+ rawTokenMetaMap.set(rawTokenId, { id: tokenId, meta });
473
+ }
474
+ }
475
+ }
476
+ catch {
477
+ /* isolated */
478
+ }
479
+ const allSentRecords = await loadSentTokenRecords();
480
+ const walletSentRecords = allSentRecords.filter((r) => r.senderSparkAddress === sparkAddress);
481
+ const sentRecords = requestedAsset && requestedAsset !== 'BTC'
482
+ ? walletSentRecords.filter((r) => tokenRefsMatch(r.assetId, requestedAsset))
483
+ : walletSentRecords;
484
+ const sentHashSet = new Set(sentRecords.map((r) => normalizeTxHash(r.hash)));
485
+ const storedRecordMap = new Map(sentRecords.map((r) => [normalizeTxHash(r.hash), r]));
486
+ const storedAmountMap = new Map(sentRecords.map((r) => [normalizeTxHash(r.hash), BigInt(Math.round(r.amount || 0))]));
487
+ const txsWithStatus = [];
488
+ try {
489
+ const result = await wallet.queryTokenTransactions({
490
+ ownerPublicKeys: [identityPubKey],
491
+ tokenIdentifiers: requestedAsset && requestedAsset !== 'BTC' ? [requestedAsset] : undefined,
492
+ pageSize: limit,
493
+ });
494
+ txsWithStatus.push(...(result.tokenTransactionsWithStatus ?? []));
495
+ }
496
+ catch {
497
+ /* isolated */
498
+ }
499
+ // Sends with no change output are invisible to the owner-filtered query — fetch by hash.
500
+ if (sentRecords.length > 0) {
501
+ try {
502
+ const sentResult = await wallet.queryTokenTransactionsByTxHashes(sentRecords.map((r) => normalizeTxHash(r.hash)));
503
+ const existing = new Set(txsWithStatus.map((t) => txHashFromBytes(t.tokenTransactionHash)));
504
+ for (const sentTx of sentResult.tokenTransactionsWithStatus ?? []) {
505
+ if (!existing.has(txHashFromBytes(sentTx.tokenTransactionHash)))
506
+ txsWithStatus.push(sentTx);
507
+ }
508
+ }
509
+ catch {
510
+ /* isolated */
511
+ }
512
+ }
513
+ const renderedSendHashes = new Set();
514
+ for (const txWithStatus of txsWithStatus) {
515
+ const converted = convertTokenTransactionToUnified(txWithStatus, identityPubKey, tokenMetaMap, rawTokenMetaMap, sentHashSet, storedRecordMap, storedAmountMap, networkType, requestedAsset && requestedAsset !== 'BTC' ? requestedAsset : undefined, requestedTokenRawId);
516
+ if (converted) {
517
+ tokenTxs.push(converted);
518
+ const hash = txHashFromBytes(txWithStatus.tokenTransactionHash);
519
+ if (sentHashSet.has(hash))
520
+ renderedSendHashes.add(hash);
521
+ }
522
+ }
523
+ // Offline / failed-fetch fallback: synthesize from any recorded send the gateway did not return.
524
+ for (const record of sentRecords) {
525
+ const hash = normalizeTxHash(record.hash);
526
+ if (renderedSendHashes.has(hash))
527
+ continue;
528
+ tokenTxs.push(buildSentRecordTransaction(record, requestedAsset && requestedAsset !== 'BTC' ? requestedAsset : undefined));
529
+ }
530
+ }
531
+ catch {
532
+ /* isolated — token history is additive to BTC history */
533
+ }
534
+ }
535
+ const allTxs = [...btcTxs, ...tokenTxs].sort((a, b) => b.timestamp - a.timestamp);
536
+ return allTxs.filter((tx) => {
537
+ if (!filter)
538
+ return true;
539
+ if (filter.asset &&
540
+ tx.asset?.id !== filter.asset &&
541
+ tx.asset?.ticker !== filter.asset &&
542
+ !tokenRefsMatch(tx.asset?.id, filter.asset))
543
+ return false;
544
+ if (filter.type && tx.type !== filter.type)
545
+ return false;
546
+ if (filter.status && tx.status !== filter.status)
547
+ return false;
548
+ if (filter.fromTimestamp && tx.timestamp < filter.fromTimestamp)
549
+ return false;
550
+ if (filter.toTimestamp && tx.timestamp > filter.toTimestamp)
551
+ return false;
552
+ return true;
553
+ });
323
554
  }
324
555
  async getTransaction(txId) {
325
556
  this.assertConnected();
326
557
  const t = await this.account.getTransactionReceipt(txId);
327
558
  if (!t)
328
- throw new ProtocolError(`Unknown tx ${txId}`, 'SPARK', 'NO_TX');
559
+ throw new ProtocolError(`Transaction not found: ${txId}`, 'SPARK', 'TX_NOT_FOUND');
329
560
  return this.toUnifiedTx(t);
330
561
  }
331
- async getNodeInfo() {
332
- return { protocol: 'SPARK', network: this.network };
333
- }
334
- async listChannels() {
335
- return []; // Spark has no LN channels
336
- }
337
- /**
338
- * Escape hatch: the underlying spark-sdk SparkWallet, for integrations that need the
339
- * raw client (e.g. the flashnet Spark-DEX, which piggybacks on a SparkWallet). Returns
340
- * the same instance this adapter uses (no duplicate wallet). Null if not connected.
341
- */
342
- getUnderlyingSparkWallet() {
343
- return this.account?._wallet ?? null;
344
- }
345
- async listPayments() {
346
- // Outgoing transfers only.
347
- const txs = await this.listTransactions();
348
- return txs.filter((t) => t.type === 'send');
349
- }
350
- async listTransfers() {
351
- this.assertConnected();
352
- return this.account.getTransfers({ limit: 100 });
353
- }
354
562
  /** Map a spark-sdk Transfer (proto) → domain UnifiedTransaction (fields read defensively). */
355
563
  toUnifiedTx(t) {
356
564
  // The spark-sdk Transfer proto has no direction flag — direction is whether
@@ -387,5 +595,280 @@ export class SparkWdkAdapter extends BaseWdkAdapter {
387
595
  protocolData: t,
388
596
  };
389
597
  }
598
+ // --- Node & balance -----------------------------------------------------
599
+ async getNodeInfo() {
600
+ this.assertConnected();
601
+ const { balance } = await getSparkBalanceCached(this.rawWallet);
602
+ const balanceSats = Number(balance);
603
+ return {
604
+ channelsBalanceMsat: balanceSats * 1000,
605
+ maxPayableMsat: balanceSats * 1000,
606
+ onchainBalanceMsat: 0,
607
+ pendingOnchainBalanceMsat: 0,
608
+ maxReceivableMsat: 0,
609
+ inboundLiquidityMsats: 0,
610
+ connectedPeers: [],
611
+ utxos: 0,
612
+ };
613
+ }
614
+ async listChannels() {
615
+ return []; // Spark has no LN channels
616
+ }
617
+ async listPayments() {
618
+ // Outgoing transfers only.
619
+ const txs = await this.listTransactions();
620
+ return { transfers: txs.filter((t) => t.type === 'send') };
621
+ }
622
+ async listTransfers() {
623
+ // Spark has no RGB-style per-asset transfers.
624
+ return { transfers: [] };
625
+ }
626
+ /**
627
+ * Escape hatch: the underlying spark-sdk SparkWallet, for integrations that need the
628
+ * raw client (e.g. the flashnet Spark-DEX, which piggybacks on a SparkWallet). Returns
629
+ * the same instance this adapter uses. Null if not connected.
630
+ */
631
+ getUnderlyingSparkWallet() {
632
+ return this.account?._wallet ?? null;
633
+ }
634
+ // --- Deposits (L1) ------------------------------------------------------
635
+ async claimSparkL1Deposit(params) {
636
+ this.assertConnected();
637
+ const address = params.address?.trim();
638
+ if (!address)
639
+ return { status: 'error', error: 'address is required' };
640
+ const wallet = this.rawWallet;
641
+ let utxos;
642
+ try {
643
+ utxos = await wallet.getUtxosForDepositAddress(address, 10, 0, true);
644
+ }
645
+ catch (error) {
646
+ return { status: 'error', error: error instanceof Error ? error.message : 'utxo lookup failed' };
647
+ }
648
+ if (!utxos || utxos.length === 0)
649
+ return { status: 'awaiting' };
650
+ const claimedTxids = [];
651
+ let lastError;
652
+ for (const utxo of utxos) {
653
+ try {
654
+ await wallet.claimDeposit(utxo.txid);
655
+ claimedTxids.push(utxo.txid);
656
+ }
657
+ catch (error) {
658
+ lastError = error instanceof Error ? error.message : String(error);
659
+ }
660
+ }
661
+ if (claimedTxids.length === 0)
662
+ return { status: 'error', error: lastError ?? 'no utxos claimed' };
663
+ invalidateSparkBalanceCache();
664
+ return { status: 'claimed', txids: claimedTxids };
665
+ }
666
+ async sweepSparkL1Deposits() {
667
+ this.assertConnected();
668
+ const wallet = this.rawWallet;
669
+ let unused;
670
+ try {
671
+ unused = await wallet.getUnusedDepositAddresses();
672
+ }
673
+ catch (error) {
674
+ return {
675
+ addressesChecked: 0,
676
+ claimedTxids: [],
677
+ errors: [error instanceof Error ? error.message : 'getUnusedDepositAddresses failed'],
678
+ };
679
+ }
680
+ if (!unused || unused.length === 0)
681
+ return { addressesChecked: 0, claimedTxids: [], errors: [] };
682
+ const claimedTxids = [];
683
+ const errors = [];
684
+ for (const addr of unused) {
685
+ try {
686
+ const utxos = await wallet.getUtxosForDepositAddress(addr, 10, 0, true);
687
+ if (!utxos || utxos.length === 0)
688
+ continue;
689
+ for (const utxo of utxos) {
690
+ try {
691
+ await wallet.claimDeposit(utxo.txid);
692
+ claimedTxids.push(utxo.txid);
693
+ }
694
+ catch (claimErr) {
695
+ errors.push(claimErr instanceof Error ? claimErr.message : String(claimErr));
696
+ }
697
+ }
698
+ }
699
+ catch (lookupErr) {
700
+ errors.push(lookupErr instanceof Error ? lookupErr.message : String(lookupErr));
701
+ }
702
+ }
703
+ if (claimedTxids.length > 0)
704
+ invalidateSparkBalanceCache();
705
+ return { addressesChecked: unused.length, claimedTxids, errors };
706
+ }
707
+ // --- On-chain / asset send ---------------------------------------------
708
+ async sendBtcOnchain(params) {
709
+ this.assertConnected();
710
+ const wallet = this.rawWallet;
711
+ try {
712
+ const feeQuote = await wallet.getWithdrawalFeeQuote({
713
+ amountSats: params.amount,
714
+ withdrawalAddress: params.address,
715
+ });
716
+ if (!feeQuote)
717
+ throw new Error('Failed to get withdrawal fee quote');
718
+ const feeAmountSats = (feeQuote.l1BroadcastFeeMedium?.originalValue ?? 0) + (feeQuote.userFeeMedium?.originalValue ?? 0);
719
+ const result = await wallet.withdraw({
720
+ onchainAddress: params.address,
721
+ amountSats: params.amount,
722
+ exitSpeed: this.sdk?.ExitSpeed?.MEDIUM ?? 'MEDIUM',
723
+ feeQuoteId: feeQuote.id,
724
+ feeAmountSats,
725
+ });
726
+ return result;
727
+ }
728
+ finally {
729
+ invalidateSparkBalanceCache();
730
+ }
731
+ }
732
+ async sendAsset(params) {
733
+ this.assertConnected();
734
+ const wallet = this.rawWallet;
735
+ const assignmentAmount = params.assignment?.value;
736
+ const tokenAmount = typeof assignmentAmount === 'number' && assignmentAmount > 0 ? assignmentAmount : params.amount;
737
+ if (!Number.isFinite(tokenAmount) || tokenAmount <= 0) {
738
+ throw new ProtocolError('Spark token amount must be greater than 0', 'SPARK', 'SEND_ASSET_ERROR');
739
+ }
740
+ const destination = params.recipientId.trim();
741
+ const senderSparkAddress = await wallet.getSparkAddress();
742
+ // Resolve token metadata for the send-record (cached balance is warm from the send UI).
743
+ let sentMeta = { ticker: 'TOKEN', name: params.assetId, decimals: 0 };
744
+ try {
745
+ const { tokenBalances } = await getSparkBalanceCached(wallet);
746
+ const info = tokenBalances?.get(params.assetId);
747
+ if (info) {
748
+ sentMeta = {
749
+ ticker: info.tokenMetadata.tokenTicker,
750
+ name: info.tokenMetadata.tokenName,
751
+ decimals: info.tokenMetadata.decimals,
752
+ };
753
+ }
754
+ }
755
+ catch {
756
+ /* non-critical */
757
+ }
758
+ try {
759
+ // Spark token invoice → fulfillSparkInvoice.
760
+ if (this.sdk?.isValidSparkAddress?.(destination)) {
761
+ const network = this.sdk.getNetworkFromSparkAddress(destination);
762
+ const decoded = this.sdk.decodeSparkAddress(destination, network);
763
+ if (decoded.sparkInvoiceFields) {
764
+ const response = await wallet.fulfillSparkInvoice([
765
+ { invoice: destination, amount: BigInt(tokenAmount) },
766
+ ]);
767
+ if (response.tokenTransactionErrors?.length > 0)
768
+ throw new Error(response.tokenTransactionErrors[0].error.message);
769
+ if (response.invalidInvoices?.length > 0)
770
+ throw new Error(response.invalidInvoices[0].error.message);
771
+ const success = response.tokenTransactionSuccess?.[0];
772
+ if (success) {
773
+ await saveSentTokenRecord({
774
+ hash: success.txid,
775
+ senderSparkAddress,
776
+ amount: tokenAmount,
777
+ assetId: params.assetId,
778
+ ...sentMeta,
779
+ timestamp: Date.now(),
780
+ });
781
+ invalidateSparkBalanceCache();
782
+ return { txId: success.txid };
783
+ }
784
+ const satsSuccess = response.satsTransactionSuccess?.[0];
785
+ if (satsSuccess)
786
+ return { txId: satsSuccess.transferResponse.id };
787
+ throw new Error('Spark invoice payment returned no result');
788
+ }
789
+ }
790
+ // Plain Spark address → transferTokens.
791
+ const txId = await wallet.transferTokens({
792
+ tokenIdentifier: params.assetId,
793
+ tokenAmount: BigInt(tokenAmount),
794
+ receiverSparkAddress: destination,
795
+ });
796
+ await saveSentTokenRecord({
797
+ hash: txId,
798
+ senderSparkAddress,
799
+ amount: tokenAmount,
800
+ assetId: params.assetId,
801
+ ...sentMeta,
802
+ timestamp: Date.now(),
803
+ });
804
+ invalidateSparkBalanceCache();
805
+ return { txId };
806
+ }
807
+ catch (error) {
808
+ const msg = error instanceof Error ? error.message : String(error);
809
+ throw new ProtocolError(`Failed to send Spark token: ${msg}`, 'SPARK', 'SEND_ASSET_ERROR', error);
810
+ }
811
+ }
812
+ // --- Invoice status -----------------------------------------------------
813
+ async getInvoiceStatus(params) {
814
+ this.assertConnected();
815
+ const requestId = this.invoiceRequestIds.get(params.invoice);
816
+ if (!requestId)
817
+ return { status: 'Pending' }; // untracked (e.g. previous session)
818
+ try {
819
+ const request = await this.rawWallet.getLightningReceiveRequest(requestId);
820
+ if (!request)
821
+ return { status: 'Pending' };
822
+ const s = request.status;
823
+ if (s === 'LIGHTNING_PAYMENT_RECEIVED' || s === 'TRANSFER_COMPLETED' || s === 'PAYMENT_PREIMAGE_RECOVERED') {
824
+ this.invoiceRequestIds.delete(params.invoice);
825
+ return { status: 'Succeeded' };
826
+ }
827
+ if (s === 'TRANSFER_FAILED' ||
828
+ s === 'TRANSFER_CREATION_FAILED' ||
829
+ s === 'REFUND_SIGNING_COMMITMENTS_QUERYING_FAILED' ||
830
+ s === 'REFUND_SIGNING_FAILED' ||
831
+ s === 'PAYMENT_PREIMAGE_RECOVERING_FAILED') {
832
+ this.invoiceRequestIds.delete(params.invoice);
833
+ return { status: 'Failed' };
834
+ }
835
+ return { status: 'Pending' };
836
+ }
837
+ catch {
838
+ return { status: 'Pending' };
839
+ }
840
+ }
841
+ // --- RGB (not supported by Spark) --------------------------------------
842
+ async createRgbInvoice() {
843
+ throw new ProtocolError('RGB invoices not supported by Spark', 'SPARK', 'NOT_SUPPORTED');
844
+ }
845
+ async decodeRgbInvoice() {
846
+ throw new ProtocolError('RGB invoice decoding not supported by Spark', 'SPARK', 'NOT_SUPPORTED');
847
+ }
848
+ // --- Message / PSBT signing --------------------------------------------
849
+ async signPsbt(psbtHex) {
850
+ if (!this.mnemonic)
851
+ throw new ProtocolError('Wallet mnemonic not available', 'SPARK', 'NOT_CONNECTED');
852
+ const { signPsbt: doSign } = await import('../../lib/psbt-signer.js');
853
+ const result = doSign(psbtHex, this.mnemonic);
854
+ return { psbt: result.psbt, unchanged: result.unchanged };
855
+ }
856
+ async signMessage(message) {
857
+ if (!this.mnemonic)
858
+ throw new ProtocolError('Wallet mnemonic not available', 'SPARK', 'NOT_CONNECTED');
859
+ const { mnemonicToSeedSync } = await import('@scure/bip39');
860
+ const { HDKey } = await import('@scure/bip32');
861
+ const seed = mnemonicToSeedSync(this.mnemonic);
862
+ const root = HDKey.fromMasterSeed(seed);
863
+ // m/138'/1 — wallet-identity message-signing key (distinct from LNURL-auth's m/138'/0).
864
+ const node = root.derive("m/138'/1");
865
+ if (!node.privateKey) {
866
+ throw new ProtocolError('Failed to derive message-signing key', 'SPARK', 'KEY_DERIVATION_ERROR');
867
+ }
868
+ return signLnMessage(message, node.privateKey);
869
+ }
870
+ async verifyMessage(message, signature) {
871
+ return verifyLnMessage(message, signature);
872
+ }
390
873
  }
391
874
  //# sourceMappingURL=SparkWdkAdapter.js.map