@qntx/spark 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,373 @@
1
+ import { r as SparkInputError } from "./errors-5xLkExNL.mjs";
2
+ import { t as serverErrorText } from "./text-DC_Vptn6.mjs";
3
+ import { a as lightningAddressRegisterMessage, c as lightningAddressUnregisterMessage, i as lightningAddressMessageDigest, n as checkLightningAddressDomain, o as lightningAddressStatementHash, s as lightningAddressTimestampFresh, t as LIGHTNING_ADDRESS_USERNAME_PATTERN } from "./lightning-address-CZrb8x4p.mjs";
4
+ import { m as encodeLnurl, v as isJsonObject } from "./lnurl-kcdXU75g.mjs";
5
+ import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
6
+ import { secp256k1 } from "@noble/curves/secp256k1.js";
7
+ import { sha256 } from "@noble/hashes/sha2.js";
8
+ //#region src/server/store.ts
9
+ /**
10
+ * In-memory {@link LightningAddressStore} — a reference implementation and the test store. `now` is
11
+ * injectable so statement claims can prune expired entries deterministically.
12
+ */
13
+ function createMemoryLightningAddressStore(now = Date.now) {
14
+ const byName = /* @__PURE__ */ new Map();
15
+ const byPubkey = /* @__PURE__ */ new Map();
16
+ const claimed = /* @__PURE__ */ new Map();
17
+ const invoices = /* @__PURE__ */ new Map();
18
+ return {
19
+ userByName: async (name) => {
20
+ await Promise.resolve();
21
+ return byName.get(name);
22
+ },
23
+ userByPublicKey: async (pubkeyHex) => {
24
+ await Promise.resolve();
25
+ return byPubkey.get(pubkeyHex);
26
+ },
27
+ countUsers: async () => {
28
+ await Promise.resolve();
29
+ return byName.size;
30
+ },
31
+ putUser: async (user) => {
32
+ await Promise.resolve();
33
+ const holder = byName.get(user.name);
34
+ if (holder !== void 0 && holder.pubkeyHex !== user.pubkeyHex) return false;
35
+ const previous = byPubkey.get(user.pubkeyHex);
36
+ if (previous !== void 0 && previous.name !== user.name) byName.delete(previous.name);
37
+ byName.set(user.name, user);
38
+ byPubkey.set(user.pubkeyHex, user);
39
+ return true;
40
+ },
41
+ deleteUser: async (pubkeyHex, name) => {
42
+ await Promise.resolve();
43
+ const held = byName.get(name);
44
+ if (held === void 0 || held.pubkeyHex !== pubkeyHex) return false;
45
+ byName.delete(name);
46
+ byPubkey.delete(pubkeyHex);
47
+ return true;
48
+ },
49
+ claimStatement: async (hashHex, expiresAtMs) => {
50
+ await Promise.resolve();
51
+ const nowMs = now();
52
+ for (const [hash, expiry] of claimed) if (expiry <= nowMs) claimed.delete(hash);
53
+ if (claimed.has(hashHex)) return false;
54
+ claimed.set(hashHex, expiresAtMs);
55
+ return true;
56
+ },
57
+ putInvoice: async (invoice) => {
58
+ await Promise.resolve();
59
+ invoices.set(invoice.paymentHash, invoice);
60
+ },
61
+ invoiceByPaymentHash: async (hashHex) => {
62
+ await Promise.resolve();
63
+ return invoices.get(hashHex);
64
+ },
65
+ pendingInvoices: async (nowMs, limit) => {
66
+ await Promise.resolve();
67
+ return [...invoices.values()].filter((invoice) => invoice.preimageHex === void 0 && invoice.expiresAtMs > nowMs).sort((a, b) => (a.lastCheckedAtMs ?? 0) - (b.lastCheckedAtMs ?? 0) || a.expiresAtMs - b.expiresAtMs).slice(0, limit);
68
+ },
69
+ markChecked: async (hashHex, nowMs) => {
70
+ await Promise.resolve();
71
+ const invoice = invoices.get(hashHex);
72
+ if (invoice !== void 0) invoices.set(hashHex, {
73
+ ...invoice,
74
+ lastCheckedAtMs: nowMs
75
+ });
76
+ },
77
+ countPendingInvoices: async (nowMs, pubkeyHex) => {
78
+ await Promise.resolve();
79
+ let count = 0;
80
+ for (const invoice of invoices.values()) if (invoice.preimageHex === void 0 && invoice.expiresAtMs > nowMs && (pubkeyHex === void 0 || invoice.pubkeyHex === pubkeyHex)) count += 1;
81
+ return count;
82
+ },
83
+ pruneInvoices: async (nowMs, settledRetentionMs) => {
84
+ await Promise.resolve();
85
+ let removed = 0;
86
+ for (const [hash, invoice] of invoices) {
87
+ const settled = invoice.preimageHex !== void 0;
88
+ if (!settled && invoice.expiresAtMs <= nowMs || settled && invoice.expiresAtMs <= nowMs - settledRetentionMs) {
89
+ invoices.delete(hash);
90
+ removed += 1;
91
+ }
92
+ }
93
+ return removed;
94
+ },
95
+ settleInvoice: async (hashHex, preimageHex) => {
96
+ await Promise.resolve();
97
+ const invoice = invoices.get(hashHex);
98
+ if (invoice === void 0) return;
99
+ const settled = {
100
+ ...invoice,
101
+ preimageHex
102
+ };
103
+ invoices.set(hashHex, settled);
104
+ return settled;
105
+ }
106
+ };
107
+ }
108
+ //#endregion
109
+ //#region src/server/index.ts
110
+ function result(status, body) {
111
+ return {
112
+ status,
113
+ body
114
+ };
115
+ }
116
+ /** LUD convention: business errors ride a 200 with the `{"status": "ERROR"}` envelope. */
117
+ function lnurlError(reason) {
118
+ return result(200, {
119
+ status: "ERROR",
120
+ reason
121
+ });
122
+ }
123
+ /** `settlePending` polls at most this many invoice statuses in parallel. */
124
+ const SETTLE_CONCURRENCY = 8;
125
+ const DEFAULT_INVOICE_EXPIRY_SECS = 3600;
126
+ const DEFAULT_MAX_PENDING_PER_USER = 100;
127
+ const DEFAULT_MAX_PENDING = 1e4;
128
+ const DEFAULT_MAX_USERS = 1e5;
129
+ const DEFAULT_SETTLE_BATCH = 200;
130
+ function positiveInt(value, fallback, name) {
131
+ const resolved = value ?? fallback;
132
+ if (!Number.isSafeInteger(resolved) || resolved < 1) throw new SparkInputError(`lightning address server: ${name} must be a positive integer`);
133
+ return resolved;
134
+ }
135
+ function asStringField(body, field) {
136
+ const value = body[field];
137
+ if (typeof value !== "string") throw new SparkInputError(`${field} is not a string`);
138
+ return value;
139
+ }
140
+ function asTimestampField(body) {
141
+ const value = body["timestamp"];
142
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new SparkInputError("timestamp is not a non-negative integer");
143
+ return value;
144
+ }
145
+ function parseKeyAndSignature(pubkeyHex, signatureHex) {
146
+ let pubkey;
147
+ try {
148
+ pubkey = secp256k1.Point.fromBytes(hexToBytes(pubkeyHex));
149
+ } catch {
150
+ return result(400, "invalid pubkey");
151
+ }
152
+ let signature;
153
+ try {
154
+ signature = secp256k1.Signature.fromBytes(hexToBytes(signatureHex), "der").toBytes("der");
155
+ } catch {
156
+ return result(400, "invalid signature");
157
+ }
158
+ return {
159
+ pubkey: pubkey.toBytes(true),
160
+ pubkeyHex: bytesToHex(pubkey.toBytes(true)),
161
+ signature
162
+ };
163
+ }
164
+ function verifyStatement(pubkey, signature, message) {
165
+ try {
166
+ return secp256k1.verify(signature, lightningAddressMessageDigest(message), pubkey, {
167
+ format: "der",
168
+ prehash: false
169
+ });
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+ /**
175
+ * Creates the Lightning Address handlers for one `domain`. The registration LNURL handed out is the
176
+ * LUD-01 bech32 encoding of `https://{domain}/lnurlp/{name}` (not the `lnurlp://` LUD-17 scheme) —
177
+ * every LUD-16 wallet resolves the bare address without scheme support.
178
+ */
179
+ function createLightningAddressServer(options) {
180
+ const domain = options.domain.toLowerCase();
181
+ checkLightningAddressDomain(domain);
182
+ if (!Number.isSafeInteger(options.minSendableMsat) || options.minSendableMsat < 1 || !Number.isSafeInteger(options.maxSendableMsat) || options.maxSendableMsat < options.minSendableMsat) throw new SparkInputError("lightning address server: invalid sendable bounds");
183
+ if (!Number.isSafeInteger(options.commentAllowed) || options.commentAllowed < 0) throw new SparkInputError("lightning address server: invalid commentAllowed");
184
+ if (options.zap !== void 0 && !/^[0-9a-f]{64}$/u.test(options.zap.receiptPublicKey)) throw new SparkInputError("lightning address server: zap.receiptPublicKey must be 32-byte lowercase hex");
185
+ const { client, store } = options;
186
+ const { zap } = options;
187
+ const invoiceExpirySecs = positiveInt(options.invoiceExpirySecs, DEFAULT_INVOICE_EXPIRY_SECS, "invoiceExpirySecs");
188
+ const maxPendingPerUser = positiveInt(options.maxPendingInvoicesPerUser, DEFAULT_MAX_PENDING_PER_USER, "maxPendingInvoicesPerUser");
189
+ const maxPending = positiveInt(options.maxPendingInvoices, DEFAULT_MAX_PENDING, "maxPendingInvoices");
190
+ const maxUsers = positiveInt(options.maxUsers, DEFAULT_MAX_USERS, "maxUsers");
191
+ const settleBatch = positiveInt(options.settleBatch, DEFAULT_SETTLE_BATCH, "settleBatch");
192
+ const now = options.now ?? (() => Date.now());
193
+ const nowSecs = () => Math.floor(now() / 1e3);
194
+ function metadataOf(user) {
195
+ return JSON.stringify([["text/plain", user.description], ["text/identifier", `${user.name}@${domain}`]]);
196
+ }
197
+ /**
198
+ * Claims the signed statement before anything else — replay protection burns the signature
199
+ * unconditionally, so a registration that then loses a name race still consumes its statement and
200
+ * the caller must sign a fresh one (its timestamp window has moved on anyway).
201
+ */
202
+ async function claimAndStore(pubkeyHex, message, timestamp, user) {
203
+ const statementHash = lightningAddressStatementHash(pubkeyHex, message);
204
+ if (!await store.claimStatement(statementHash, (timestamp + 600) * 1e3)) return result(409, "signature has already been used");
205
+ if (user !== void 0) {
206
+ if (await store.userByPublicKey(user.pubkeyHex) === void 0 && await store.countUsers() >= maxUsers) return result(409, "registered user limit reached");
207
+ if (!await store.putUser(user)) return result(409, "name already taken");
208
+ }
209
+ }
210
+ async function register(pubkeyHex, body) {
211
+ if (!isJsonObject(body)) return result(400, "invalid request body");
212
+ let parsed;
213
+ try {
214
+ parsed = {
215
+ username: asStringField(body, "username").toLowerCase(),
216
+ signature: asStringField(body, "signature"),
217
+ timestamp: asTimestampField(body),
218
+ description: asStringField(body, "description")
219
+ };
220
+ } catch {
221
+ return result(400, "invalid request body");
222
+ }
223
+ if (!LIGHTNING_ADDRESS_USERNAME_PATTERN.test(parsed.username)) return result(400, "invalid username");
224
+ if (parsed.description.length > 512) return result(400, "description too long");
225
+ const key = parseKeyAndSignature(pubkeyHex, parsed.signature);
226
+ if ("status" in key) return key;
227
+ if (!lightningAddressTimestampFresh(parsed.timestamp, nowSecs())) return result(400, "invalid timestamp");
228
+ const message = lightningAddressRegisterMessage(domain, parsed.username, parsed.description, parsed.timestamp);
229
+ if (!verifyStatement(key.pubkey, key.signature, message)) return result(400, `invalid signature for domain '${domain}'`);
230
+ const failure = await claimAndStore(key.pubkeyHex, message, parsed.timestamp, {
231
+ name: parsed.username,
232
+ pubkeyHex: key.pubkeyHex,
233
+ description: parsed.description
234
+ });
235
+ if (failure !== void 0) return failure;
236
+ return result(200, {
237
+ lnurl: encodeLnurl(`https://${domain}/lnurlp/${parsed.username}`),
238
+ lightning_address: `${parsed.username}@${domain}`
239
+ });
240
+ }
241
+ async function unregister(pubkeyHex, body) {
242
+ if (!isJsonObject(body)) return result(400, "invalid request body");
243
+ let parsed;
244
+ try {
245
+ parsed = {
246
+ username: asStringField(body, "username").toLowerCase(),
247
+ signature: asStringField(body, "signature"),
248
+ timestamp: asTimestampField(body)
249
+ };
250
+ } catch {
251
+ return result(400, "invalid request body");
252
+ }
253
+ if (!LIGHTNING_ADDRESS_USERNAME_PATTERN.test(parsed.username)) return result(400, "invalid username");
254
+ const key = parseKeyAndSignature(pubkeyHex, parsed.signature);
255
+ if ("status" in key) return key;
256
+ if (!lightningAddressTimestampFresh(parsed.timestamp, nowSecs())) return result(400, "invalid timestamp");
257
+ const message = lightningAddressUnregisterMessage(domain, parsed.username, parsed.timestamp);
258
+ if (!verifyStatement(key.pubkey, key.signature, message)) return result(400, `invalid signature for domain '${domain}'`);
259
+ const failure = await claimAndStore(key.pubkeyHex, message, parsed.timestamp, void 0);
260
+ if (failure !== void 0) return failure;
261
+ const held = await store.userByPublicKey(key.pubkeyHex);
262
+ if (held === void 0) return result(200, "");
263
+ if (held.name !== parsed.username) return result(400, "username does not match the registered name");
264
+ if (!await store.deleteUser(key.pubkeyHex, parsed.username)) return result(400, "username does not match the registered name");
265
+ return result(200, "");
266
+ }
267
+ async function payRequest(name) {
268
+ const user = await store.userByName(name.toLowerCase());
269
+ if (user === void 0) return result(404, "");
270
+ return result(200, {
271
+ tag: "payRequest",
272
+ callback: `https://${domain}/lnurlp/${user.name}/invoice`,
273
+ minSendable: options.minSendableMsat,
274
+ maxSendable: options.maxSendableMsat,
275
+ metadata: metadataOf(user),
276
+ commentAllowed: options.commentAllowed,
277
+ ...zap === void 0 ? {} : {
278
+ allowsNostr: true,
279
+ nostrPubkey: zap.receiptPublicKey
280
+ }
281
+ });
282
+ }
283
+ async function invoice(name, params) {
284
+ const user = await store.userByName(name.toLowerCase());
285
+ if (user === void 0) return result(404, "");
286
+ if (typeof params.amount !== "number" || !Number.isSafeInteger(params.amount)) return lnurlError("missing amount");
287
+ if (params.amount % 1e3 !== 0) return lnurlError("amount must be a whole sat amount");
288
+ if (params.amount < options.minSendableMsat || params.amount > options.maxSendableMsat) return lnurlError("amount out of bounds");
289
+ if (params.comment !== void 0 && params.comment.length > options.commentAllowed) return lnurlError("comment too long");
290
+ if (params.nostr !== void 0) {
291
+ if (zap === void 0) return lnurlError("nostr zap not supported");
292
+ try {
293
+ zap.validateRequest(params.nostr, params.amount);
294
+ } catch (error) {
295
+ return lnurlError(serverErrorText(error instanceof Error ? error.message : String(error)));
296
+ }
297
+ }
298
+ if (await store.countPendingInvoices(now(), user.pubkeyHex) >= maxPendingPerUser || await store.countPendingInvoices(now()) >= maxPending) return lnurlError("too many pending invoices");
299
+ const descriptionHash = sha256(utf8ToBytes(params.nostr ?? metadataOf(user)));
300
+ let created;
301
+ try {
302
+ created = await client.lightning.createInvoice({
303
+ amountSats: params.amount / 1e3,
304
+ descriptionHash,
305
+ receiver: hexToBytes(user.pubkeyHex),
306
+ expirySecs: invoiceExpirySecs
307
+ });
308
+ } catch {
309
+ return lnurlError("failed to create invoice");
310
+ }
311
+ const paymentHash = bytesToHex(created.paymentHash);
312
+ await store.putInvoice({
313
+ paymentHash,
314
+ requestId: created.id,
315
+ name: user.name,
316
+ pubkeyHex: user.pubkeyHex,
317
+ invoice: created.invoice,
318
+ expiresAtMs: created.expiresAtMs,
319
+ ...params.nostr === void 0 ? {} : { zapRequest: params.nostr },
320
+ ...params.comment === void 0 ? {} : { comment: params.comment }
321
+ });
322
+ return result(200, {
323
+ pr: created.invoice,
324
+ routes: [],
325
+ verify: `https://${domain}/lnurlp/verify/${paymentHash}`
326
+ });
327
+ }
328
+ async function verify(paymentHashHex) {
329
+ const hash = paymentHashHex.toLowerCase();
330
+ const record = /^[0-9a-f]{64}$/u.test(hash) ? await store.invoiceByPaymentHash(hash) : void 0;
331
+ if (record === void 0) return lnurlError("Not found");
332
+ return result(200, {
333
+ status: "OK",
334
+ settled: record.preimageHex !== void 0,
335
+ ...record.preimageHex === void 0 ? {} : { preimage: record.preimageHex },
336
+ pr: record.invoice
337
+ });
338
+ }
339
+ async function settleOne(record) {
340
+ try {
341
+ const { preimage } = await client.lightning.receiveStatus({
342
+ requestId: record.requestId,
343
+ paymentHash: hexToBytes(record.paymentHash)
344
+ });
345
+ if (preimage === void 0) return;
346
+ return await store.settleInvoice(record.paymentHash, bytesToHex(preimage));
347
+ } catch {
348
+ return;
349
+ } finally {
350
+ await store.markChecked(record.paymentHash, now()).catch(() => void 0);
351
+ }
352
+ }
353
+ async function settlePending() {
354
+ const pending = await store.pendingInvoices(now(), settleBatch);
355
+ const settled = [];
356
+ for (let start = 0; start < pending.length; start += SETTLE_CONCURRENCY) {
357
+ const chunk = pending.slice(start, start + SETTLE_CONCURRENCY);
358
+ const results = await Promise.all(chunk.map(settleOne));
359
+ settled.push(...results.filter((invoice) => invoice !== void 0));
360
+ }
361
+ return settled;
362
+ }
363
+ return {
364
+ register,
365
+ unregister,
366
+ payRequest,
367
+ invoice,
368
+ verify,
369
+ settlePending
370
+ };
371
+ }
372
+ //#endregion
373
+ export { createLightningAddressServer, createMemoryLightningAddressStore };
@@ -0,0 +1,72 @@
1
+ import { n as SparkCrypto } from "./index-DUSx3L3J.mjs";
2
+ import { n as SparkNetwork } from "./lightning-address-CRULmi9v.mjs";
3
+ import { _ as StaticDepositClaimRequest, a as OperatorCommitment, c as PreparedClaim, d as PreparedTransferLeaf, f as SigningCommitment, g as SparkSigner, h as SparkOperator, i as IdentityStatement, l as PreparedLightningReceive, m as SparkLeafKey, n as FrostSignJob, o as PrepareClaimRequest, p as SparkKey, r as HtlcTimeoutSignRequest, s as PrepareTransferRequest, t as FrostAggregateJob, u as PreparedTransfer, v as SigningTarget } from "./types-jBCznYd6.mjs";
4
+ import { secp256k1 } from "@noble/curves/secp256k1.js";
5
+ //#region src/signer/keys.d.ts
6
+ /**
7
+ * The 64-byte extended private key (private key ‖ chain code) at `m/8797555'/account'` — the input
8
+ * {@link createSparkSigner} expects. The default account matches the official SDK: mainnet 1,
9
+ * regtest 0.
10
+ */
11
+ export declare function sparkAccountKey(seed: Uint8Array, network: SparkNetwork, account?: number): Uint8Array;
12
+ //#endregion
13
+ //#region src/signer/local.d.ts
14
+ /**
15
+ * Creates the local Spark signer: the implementation of {@link SparkSigner} that holds the account
16
+ * extended private key and delegates curve-heavy FROST/ECIES/VSS work to a `SparkCrypto` backend.
17
+ * Throws `SparkInputError` on an ABI version mismatch or a malformed `accountKey`.
18
+ *
19
+ * The operator set, VSS threshold and SSP identity are bound here and default to the network's
20
+ * `sparkNetworkConfig`; every key-tweak share and the static-deposit secret is sealed to these keys
21
+ * and no request can retarget them. Pass explicit values only to point the signer at a different
22
+ * deployment (the local stack's operators/SSP), exactly like the client's `operators` and `ssp`
23
+ * options.
24
+ */
25
+ export declare function createSparkSigner(options: {
26
+ readonly crypto: SparkCrypto;
27
+ readonly accountKey: Uint8Array;
28
+ readonly network: SparkNetwork;
29
+ readonly random: (length: number) => Uint8Array;
30
+ /**
31
+ * Signing-operator set the key-tweak and preimage shares are sealed to; defaults to the network's
32
+ * operator set.
33
+ */
34
+ readonly operators?: ReadonlyArray<SparkOperator>;
35
+ /** VSS threshold across `operators`; defaults to the network's threshold. */
36
+ readonly threshold?: number;
37
+ /** SSP identity the static-deposit secret is sealed to; defaults to the network's SSP key. */
38
+ readonly sspIdentityPublicKey?: Uint8Array;
39
+ }): SparkSigner;
40
+ //#endregion
41
+ //#region src/signer/lnurl-auth.d.ts
42
+ /**
43
+ * The 64-byte extended private key (private key ‖ chain code) at `m/138'` — the input
44
+ * {@link createLnurlAuthSigner} expects. The caller wipes the result when it is done holding the
45
+ * material.
46
+ */
47
+ export declare function lnurlAuthRootKey(seed: Uint8Array): Uint8Array;
48
+ /**
49
+ * A signer that derives LUD-05 domain-specific linking keys and signs LUD-04 `k1` challenges with
50
+ * them. `domain` must already be lowercase — the LNURL layer lowercases the host when parsing; an
51
+ * uppercase input here means the caller skipped that step.
52
+ */
53
+ type LnurlAuthSigner = {
54
+ /** Compressed linking public key for `domain` (lowercase host). */
55
+ readonly linkingKey: (domain: string) => Promise<Uint8Array>;
56
+ /** DER ECDSA over the raw 32-byte k1 with the domain's linking key. */
57
+ readonly sign: (request: {
58
+ readonly domain: string;
59
+ readonly k1: Uint8Array;
60
+ }) => Promise<{
61
+ readonly linkingKey: Uint8Array;
62
+ readonly signature: Uint8Array;
63
+ }>;
64
+ /** Wipes the root key and cached material; later calls reject. */
65
+ readonly dispose: () => void;
66
+ };
67
+ /** Creates the signer from a `m/138'` extended private key produced by {@link lnurlAuthRootKey}. */
68
+ export declare function createLnurlAuthSigner(options: {
69
+ readonly rootKey: Uint8Array;
70
+ }): LnurlAuthSigner;
71
+ //#endregion
72
+ export type { FrostAggregateJob, FrostSignJob, HtlcTimeoutSignRequest, IdentityStatement, LnurlAuthSigner, OperatorCommitment, PrepareClaimRequest, PrepareTransferRequest, PreparedClaim, PreparedLightningReceive, PreparedTransfer, PreparedTransferLeaf, SigningCommitment, SigningTarget, SparkKey, SparkLeafKey, SparkNetwork, SparkOperator, SparkSigner, StaticDepositClaimRequest };