@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.
- package/dist/adapters/SparkAdapter.d.ts +93 -16
- package/dist/adapters/SparkAdapter.d.ts.map +1 -1
- package/dist/adapters/SparkAdapter.js +831 -171
- package/dist/adapters/SparkAdapter.js.map +1 -1
- package/dist/adapters/arkade.d.ts +15 -0
- package/dist/adapters/arkade.d.ts.map +1 -0
- package/dist/adapters/arkade.js +15 -0
- package/dist/adapters/arkade.js.map +1 -0
- package/dist/adapters/spark.d.ts +11 -0
- package/dist/adapters/spark.d.ts.map +1 -0
- package/dist/adapters/spark.js +11 -0
- package/dist/adapters/spark.js.map +1 -0
- package/dist/lib/psbt-signer.d.ts +60 -0
- package/dist/lib/psbt-signer.d.ts.map +1 -0
- package/dist/lib/psbt-signer.js +161 -0
- package/dist/lib/psbt-signer.js.map +1 -0
- package/dist/lib/spark-activity.d.ts +5 -0
- package/dist/lib/spark-activity.d.ts.map +1 -0
- package/dist/lib/spark-activity.js +11 -0
- package/dist/lib/spark-activity.js.map +1 -0
- package/dist/lib/spark-balance-cache.d.ts +58 -0
- package/dist/lib/spark-balance-cache.d.ts.map +1 -0
- package/dist/lib/spark-balance-cache.js +86 -0
- package/dist/lib/spark-balance-cache.js.map +1 -0
- package/dist/lib/spark-client-manager.d.ts +54 -9
- package/dist/lib/spark-client-manager.d.ts.map +1 -1
- package/dist/lib/spark-client-manager.js +176 -35
- package/dist/lib/spark-client-manager.js.map +1 -1
- package/dist/lib/spark-converters.d.ts +64 -0
- package/dist/lib/spark-converters.d.ts.map +1 -0
- package/dist/lib/spark-converters.js +242 -0
- package/dist/lib/spark-converters.js.map +1 -0
- package/dist/lib/spark-helpers.d.ts +72 -0
- package/dist/lib/spark-helpers.d.ts.map +1 -0
- package/dist/lib/spark-helpers.js +151 -0
- package/dist/lib/spark-helpers.js.map +1 -0
- package/dist/lib/spark-sent-token-records.d.ts +43 -0
- package/dist/lib/spark-sent-token-records.d.ts.map +1 -0
- package/dist/lib/spark-sent-token-records.js +105 -0
- package/dist/lib/spark-sent-token-records.js.map +1 -0
- package/package.json +11 -2
|
@@ -1,19 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Spark Protocol Adapter
|
|
3
|
-
* Implements IProtocolAdapter using @buildonspark/spark-sdk.
|
|
4
|
-
*
|
|
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 {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
73
|
+
log.info("[SparkAdapter] Connected to Spark successfully");
|
|
30
74
|
}
|
|
31
75
|
catch (error) {
|
|
32
|
-
|
|
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
|
-
|
|
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(
|
|
90
|
+
throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
|
|
46
91
|
}
|
|
47
92
|
try {
|
|
48
93
|
const wallet = sparkClientManager.getWallet();
|
|
49
|
-
await wallet
|
|
94
|
+
await getSparkBalanceCached(wallet);
|
|
50
95
|
return {
|
|
51
|
-
protocol:
|
|
96
|
+
protocol: "SPARK",
|
|
52
97
|
connected: true,
|
|
53
|
-
network: this.config?.network ||
|
|
98
|
+
network: this.config?.network || "regtest",
|
|
54
99
|
syncStatus: { synced: true, progress: 100 },
|
|
55
100
|
};
|
|
56
101
|
}
|
|
57
102
|
catch (error) {
|
|
58
|
-
|
|
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(
|
|
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
|
|
116
|
+
const { balance, tokenBalances } = await getSparkBalanceCached(wallet);
|
|
71
117
|
const balanceSats = Number(balance);
|
|
72
118
|
const btcAsset = {
|
|
73
|
-
id:
|
|
74
|
-
name:
|
|
75
|
-
ticker:
|
|
119
|
+
id: "BTC",
|
|
120
|
+
name: "Bitcoin",
|
|
121
|
+
ticker: "BTC",
|
|
76
122
|
precision: 8,
|
|
77
|
-
protocol:
|
|
78
|
-
layer:
|
|
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:
|
|
85
|
-
availableDisplay:
|
|
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:
|
|
109
|
-
layer:
|
|
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:
|
|
116
|
-
availableDisplay:
|
|
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
|
-
|
|
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}`,
|
|
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
|
-
//
|
|
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(
|
|
205
|
+
throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
|
|
155
206
|
}
|
|
156
207
|
try {
|
|
157
208
|
const wallet = sparkClientManager.getWallet();
|
|
158
|
-
const
|
|
159
|
-
|
|
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
|
-
|
|
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(
|
|
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}`,
|
|
404
|
+
throw new ProtocolError(`Transaction not found: ${txId}`, "SPARK", "TX_NOT_FOUND");
|
|
174
405
|
}
|
|
175
|
-
|
|
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(`
|
|
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(
|
|
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
|
|
427
|
+
amountSats: request.amount ?? 0,
|
|
198
428
|
memo: request.description,
|
|
429
|
+
expirySeconds: request.expirySeconds,
|
|
199
430
|
});
|
|
200
|
-
const
|
|
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:
|
|
203
|
-
paymentHash:
|
|
443
|
+
invoice: encodedInvoice,
|
|
444
|
+
paymentHash: inv.paymentHash ?? "",
|
|
204
445
|
amount: request.amount,
|
|
205
|
-
expiresAt:
|
|
446
|
+
expiresAt: expiresAt ?? Date.now() + (request.expirySeconds ?? 3600) * 1000,
|
|
206
447
|
description: request.description,
|
|
207
448
|
};
|
|
208
449
|
}
|
|
209
450
|
catch (error) {
|
|
210
|
-
|
|
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
|
|
214
|
-
|
|
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:
|
|
496
|
+
destination: input,
|
|
219
497
|
};
|
|
220
498
|
}
|
|
221
499
|
async sendPayment(request) {
|
|
222
500
|
if (!this.isConnected()) {
|
|
223
|
-
throw new ProtocolError(
|
|
501
|
+
throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
|
|
224
502
|
}
|
|
225
503
|
try {
|
|
226
504
|
const wallet = sparkClientManager.getWallet();
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
249
|
-
fee: 0,
|
|
250
|
-
status:
|
|
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
|
-
|
|
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(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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(
|
|
647
|
+
throw new ProtocolError("Not connected", "SPARK", "NOT_CONNECTED");
|
|
270
648
|
}
|
|
271
649
|
try {
|
|
272
650
|
const wallet = sparkClientManager.getWallet();
|
|
273
|
-
|
|
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 {
|
|
663
|
+
return {
|
|
664
|
+
address,
|
|
665
|
+
format: "BTC_ADDRESS",
|
|
666
|
+
asset: "BTC",
|
|
667
|
+
};
|
|
276
668
|
}
|
|
277
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
//
|
|
869
|
+
// PSBT Signing
|
|
327
870
|
// ========================================================================
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
//
|
|
900
|
+
// RGB-Specific Operations (Not supported by Spark)
|
|
345
901
|
// ========================================================================
|
|
346
|
-
|
|
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
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|