@elisym/sdk 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,11 +1,9 @@
1
- import { SimplePool, getPublicKey, nip19, generateSecretKey, finalizeEvent } from 'nostr-tools';
1
+ import { PublicKey, Keypair, SystemProgram, Transaction } from '@solana/web3.js';
2
+ import Decimal2 from 'decimal.js-light';
3
+ import { verifyEvent, finalizeEvent, getPublicKey, nip19, generateSecretKey, SimplePool } from 'nostr-tools';
2
4
  import * as nip44 from 'nostr-tools/nip44';
3
5
  import * as nip17 from 'nostr-tools/nip17';
4
6
  import * as nip59 from 'nostr-tools/nip59';
5
- import { PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
6
- import Decimal from 'decimal.js-light';
7
-
8
- // src/core/pool.ts
9
7
 
10
8
  // src/constants.ts
11
9
  var RELAYS = [
@@ -24,9 +22,15 @@ var KIND_GIFT_WRAP = 1059;
24
22
  var KIND_JOB_REQUEST = KIND_JOB_REQUEST_BASE + DEFAULT_KIND_OFFSET;
25
23
  var KIND_JOB_RESULT = KIND_JOB_RESULT_BASE + DEFAULT_KIND_OFFSET;
26
24
  function jobRequestKind(offset) {
25
+ if (!Number.isInteger(offset) || offset < 0 || offset >= 1e3) {
26
+ throw new Error(`Invalid kind offset: ${offset}. Must be integer 0-999.`);
27
+ }
27
28
  return KIND_JOB_REQUEST_BASE + offset;
28
29
  }
29
30
  function jobResultKind(offset) {
31
+ if (!Number.isInteger(offset) || offset < 0 || offset >= 1e3) {
32
+ throw new Error(`Invalid kind offset: ${offset}. Must be integer 0-999.`);
33
+ }
30
34
  return KIND_JOB_RESULT_BASE + offset;
31
35
  }
32
36
  var KIND_PING = 20200;
@@ -34,192 +38,453 @@ var KIND_PONG = 20201;
34
38
  var LAMPORTS_PER_SOL = 1e9;
35
39
  var PROTOCOL_FEE_BPS = 300;
36
40
  var PROTOCOL_TREASURY = "GY7vnWMkKpftU4nQ16C2ATkj1JwrQpHhknkaBUn67VTy";
37
-
38
- // src/core/pool.ts
39
- var NostrPool = class {
40
- pool;
41
- relays;
42
- constructor(relays = RELAYS) {
43
- this.pool = new SimplePool();
44
- this.relays = relays;
41
+ var DEFAULTS = {
42
+ SUBSCRIPTION_TIMEOUT_MS: 12e4,
43
+ PING_TIMEOUT_MS: 15e3,
44
+ PING_RETRIES: 2,
45
+ PING_CACHE_TTL_MS: 3e4,
46
+ PAYMENT_EXPIRY_SECS: 600,
47
+ BATCH_SIZE: 250,
48
+ QUERY_TIMEOUT_MS: 15e3,
49
+ EOSE_TIMEOUT_MS: 3e3,
50
+ VERIFY_RETRIES: 10,
51
+ VERIFY_INTERVAL_MS: 3e3,
52
+ VERIFY_BY_REF_RETRIES: 15,
53
+ VERIFY_BY_REF_INTERVAL_MS: 2e3,
54
+ RESULT_RETRY_COUNT: 3,
55
+ RESULT_RETRY_BASE_MS: 1e3,
56
+ QUERY_MAX_CONCURRENCY: 6,
57
+ VERIFY_SIGNATURE_LIMIT: 25
58
+ };
59
+ var LIMITS = {
60
+ MAX_INPUT_LENGTH: 1e5,
61
+ MAX_TIMEOUT_SECS: 600,
62
+ MAX_CAPABILITIES: 20,
63
+ MAX_DESCRIPTION_LENGTH: 500,
64
+ MAX_AGENT_NAME_LENGTH: 64,
65
+ MAX_MESSAGE_LENGTH: 1e4,
66
+ MAX_CAPABILITY_LENGTH: 64
67
+ };
68
+ function assertLamports(value, field) {
69
+ if (!Number.isInteger(value) || value < 0) {
70
+ throw new Error(`Invalid ${field}: ${value}. Must be a non-negative integer.`);
45
71
  }
46
- async querySync(filter) {
47
- return Promise.race([
48
- this.pool.querySync(this.relays, filter),
49
- new Promise(
50
- (resolve) => setTimeout(() => resolve([]), 15e3)
51
- )
52
- ]);
53
- }
54
- async queryBatched(filter, keys, batchSize = 250) {
55
- const batches = [];
56
- for (let i = 0; i < keys.length; i += batchSize) {
57
- const batch = keys.slice(i, i + batchSize);
58
- batches.push(
59
- Promise.race([
60
- this.pool.querySync(this.relays, {
61
- ...filter,
62
- authors: batch
63
- }),
64
- new Promise(
65
- (resolve) => setTimeout(() => resolve([]), 15e3)
66
- )
67
- ])
68
- );
69
- }
70
- return (await Promise.all(batches)).flat();
72
+ }
73
+ function calculateProtocolFee(amount) {
74
+ if (!Number.isInteger(amount) || amount < 0) {
75
+ throw new Error(`Invalid fee amount: ${amount}. Must be a non-negative integer.`);
71
76
  }
72
- async queryBatchedByTag(filter, tagName, values, batchSize = 250) {
73
- const batches = [];
74
- for (let i = 0; i < values.length; i += batchSize) {
75
- const batch = values.slice(i, i + batchSize);
76
- batches.push(
77
- Promise.race([
78
- this.pool.querySync(this.relays, {
79
- ...filter,
80
- [`#${tagName}`]: batch
81
- }),
82
- new Promise(
83
- (resolve) => setTimeout(() => resolve([]), 15e3)
84
- )
85
- ])
86
- );
87
- }
88
- return (await Promise.all(batches)).flat();
77
+ if (amount === 0) {
78
+ return 0;
89
79
  }
90
- async publish(event) {
80
+ return new Decimal2(amount).mul(PROTOCOL_FEE_BPS).div(1e4).toDecimalPlaces(0, Decimal2.ROUND_CEIL).toNumber();
81
+ }
82
+ function validateExpiry(createdAt, expirySecs) {
83
+ if (!Number.isInteger(createdAt) || createdAt <= 0) {
84
+ return "Invalid or missing created_at in payment request.";
85
+ }
86
+ if (!Number.isInteger(expirySecs) || expirySecs <= 0) {
87
+ return "Invalid or missing expiry_secs in payment request.";
88
+ }
89
+ const now = Math.floor(Date.now() / 1e3);
90
+ if (createdAt > now + 120) {
91
+ return `Payment request created_at is in the future (${createdAt} vs now ${now}). Possible manipulation.`;
92
+ }
93
+ if (now - createdAt > expirySecs) {
94
+ return `Payment request expired (created ${createdAt}, expiry ${expirySecs}s).`;
95
+ }
96
+ return null;
97
+ }
98
+ function assertExpiry(createdAt, expirySecs) {
99
+ const error = validateExpiry(createdAt, expirySecs);
100
+ if (error) {
101
+ throw new Error(error);
102
+ }
103
+ }
104
+
105
+ // src/payment/solana.ts
106
+ function isValidSolanaAddress(address) {
107
+ try {
108
+ void new PublicKey(address);
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+ var SolanaPaymentStrategy = class {
115
+ chain = "solana";
116
+ calculateFee(amount) {
117
+ return calculateProtocolFee(amount);
118
+ }
119
+ createPaymentRequest(recipientAddress, amount, expirySecs = DEFAULTS.PAYMENT_EXPIRY_SECS) {
91
120
  try {
92
- await Promise.any(this.pool.publish(this.relays, event));
93
- } catch (err) {
94
- if (err instanceof AggregateError) {
95
- throw new Error(
96
- `Failed to publish to all ${this.relays.length} relays: ${err.errors.map((e) => e.message).join(", ")}`
97
- );
98
- }
99
- throw err;
121
+ void new PublicKey(recipientAddress);
122
+ } catch {
123
+ throw new Error(`Invalid Solana address: ${recipientAddress}`);
100
124
  }
101
- }
102
- /** Publish to all relays and wait for all to settle. Throws if none accepted. */
103
- async publishAll(event) {
104
- const results = await Promise.allSettled(this.pool.publish(this.relays, event));
105
- const anyOk = results.some((r) => r.status === "fulfilled");
106
- if (!anyOk) {
125
+ assertLamports(amount, "payment amount");
126
+ if (amount === 0) {
127
+ throw new Error("Invalid payment amount: 0. Must be positive.");
128
+ }
129
+ if (!Number.isInteger(expirySecs) || expirySecs <= 0 || expirySecs > LIMITS.MAX_TIMEOUT_SECS) {
107
130
  throw new Error(
108
- `Failed to publish to all ${this.relays.length} relays`
131
+ `Invalid expiry: ${expirySecs}. Must be integer 1-${LIMITS.MAX_TIMEOUT_SECS}.`
109
132
  );
110
133
  }
134
+ const feeAmount = calculateProtocolFee(amount);
135
+ const reference = Keypair.generate().publicKey.toBase58();
136
+ return {
137
+ recipient: recipientAddress,
138
+ amount,
139
+ reference,
140
+ fee_address: PROTOCOL_TREASURY,
141
+ fee_amount: feeAmount,
142
+ created_at: Math.floor(Date.now() / 1e3),
143
+ expiry_secs: expirySecs
144
+ };
111
145
  }
112
- subscribe(filter, onEvent) {
113
- return this.pool.subscribeMany(
114
- this.relays,
115
- filter,
116
- { onevent: onEvent }
117
- );
118
- }
119
- /**
120
- * Subscribe and wait until at least one relay confirms the subscription
121
- * is active (EOSE). Resolves on the first relay that responds.
122
- * Essential for ephemeral events where the subscription must be live
123
- * before publishing.
124
- */
125
- subscribeAndWait(filter, onEvent, timeoutMs = 3e3) {
126
- return new Promise((resolve) => {
127
- let resolved = false;
128
- const done = () => {
129
- if (resolved) return;
130
- resolved = true;
131
- resolve(combinedSub);
146
+ validatePaymentRequest(requestJson, expectedRecipient) {
147
+ let data;
148
+ try {
149
+ data = JSON.parse(requestJson);
150
+ } catch (e) {
151
+ return { code: "invalid_json", message: `Invalid payment request JSON: ${e}` };
152
+ }
153
+ if (typeof data.amount !== "number" || !Number.isInteger(data.amount) || data.amount <= 0) {
154
+ return {
155
+ code: "invalid_amount",
156
+ message: `Invalid amount in payment request: ${data.amount}`
132
157
  };
133
- const subs = [];
134
- for (const relay of this.relays) {
135
- const sub = this.pool.subscribeMany(
136
- [relay],
137
- filter,
138
- {
139
- onevent: onEvent,
140
- oneose: done
141
- }
142
- );
143
- subs.push(sub);
158
+ }
159
+ if (typeof data.recipient !== "string" || !data.recipient) {
160
+ return { code: "missing_recipient", message: "Missing recipient in payment request" };
161
+ }
162
+ if (!isValidSolanaAddress(data.recipient)) {
163
+ return {
164
+ code: "invalid_recipient_address",
165
+ message: `Invalid Solana address for recipient: ${data.recipient}`
166
+ };
167
+ }
168
+ if (typeof data.reference !== "string" || !data.reference) {
169
+ return { code: "missing_reference", message: "Missing reference in payment request" };
170
+ }
171
+ if (!isValidSolanaAddress(data.reference)) {
172
+ return {
173
+ code: "invalid_reference_address",
174
+ message: `Invalid Solana address for reference: ${data.reference}`
175
+ };
176
+ }
177
+ if (expectedRecipient && data.recipient !== expectedRecipient) {
178
+ return {
179
+ code: "recipient_mismatch",
180
+ message: `Recipient mismatch: expected ${expectedRecipient}, got ${data.recipient}. Provider may be attempting to redirect payment.`
181
+ };
182
+ }
183
+ const expiryError = validateExpiry(data.created_at, data.expiry_secs);
184
+ if (expiryError) {
185
+ const code = expiryError.includes("future") ? "future_timestamp" : "expired";
186
+ return { code, message: expiryError };
187
+ }
188
+ const expectedFee = calculateProtocolFee(data.amount);
189
+ const { fee_address, fee_amount } = data;
190
+ const hasFeeAddress = typeof fee_address === "string" && fee_address.length > 0;
191
+ const hasFeeAmount = typeof fee_amount === "number" && fee_amount > 0;
192
+ if (hasFeeAddress && hasFeeAmount) {
193
+ if (fee_address !== PROTOCOL_TREASURY) {
194
+ return {
195
+ code: "fee_address_mismatch",
196
+ message: `Fee address mismatch: expected ${PROTOCOL_TREASURY}, got ${fee_address}. Provider may be attempting to redirect fees.`
197
+ };
144
198
  }
145
- const combinedSub = {
146
- close: (reason) => {
147
- for (const s of subs) s.close(reason);
148
- }
199
+ if (fee_amount !== expectedFee) {
200
+ return {
201
+ code: "fee_amount_mismatch",
202
+ message: `Fee amount mismatch: expected ${expectedFee} lamports (${PROTOCOL_FEE_BPS}bps of ${data.amount}), got ${fee_amount}. Provider may be tampering with fee.`
203
+ };
204
+ }
205
+ return null;
206
+ }
207
+ if (!hasFeeAddress && (fee_amount === null || fee_amount === void 0 || fee_amount === 0)) {
208
+ return {
209
+ code: "missing_fee",
210
+ message: `Payment request missing protocol fee (${PROTOCOL_FEE_BPS}bps). Expected fee: ${expectedFee} lamports to ${PROTOCOL_TREASURY}.`
149
211
  };
150
- setTimeout(done, timeoutMs);
151
- });
152
- }
153
- /**
154
- * Tear down pool and create a fresh one.
155
- * Works around nostr-tools `onerror → skipReconnection = true` bug
156
- * that permanently kills subscriptions. Callers must re-subscribe.
157
- */
158
- reset() {
159
- try {
160
- this.pool.close(this.relays);
161
- } catch {
162
212
  }
163
- this.pool = new SimplePool();
213
+ return {
214
+ code: "invalid_fee_params",
215
+ message: `Invalid fee params in payment request. Expected fee: ${expectedFee} lamports to ${PROTOCOL_TREASURY}.`
216
+ };
164
217
  }
165
218
  /**
166
- * Lightweight connectivity probe. Returns true if at least one relay responds.
219
+ * Build an unsigned transaction from a payment request.
220
+ * The caller must set `recentBlockhash` and `feePayer` on the
221
+ * returned Transaction before signing and sending.
167
222
  */
168
- async probe(timeoutMs = 3e3) {
169
- let timer;
170
- try {
171
- await Promise.race([
172
- this.pool.querySync(this.relays, { kinds: [0], limit: 1 }),
173
- new Promise((_, reject) => {
174
- timer = setTimeout(() => reject(new Error("probe timeout")), timeoutMs);
223
+ async buildTransaction(payerAddress, paymentRequest) {
224
+ assertLamports(paymentRequest.amount, "payment amount");
225
+ if (paymentRequest.amount === 0) {
226
+ throw new Error("Invalid payment amount: 0. Must be positive.");
227
+ }
228
+ if (paymentRequest.fee_amount !== null && paymentRequest.fee_amount !== void 0 && (!Number.isInteger(paymentRequest.fee_amount) || paymentRequest.fee_amount < 0)) {
229
+ throw new Error(
230
+ `Invalid fee amount: ${paymentRequest.fee_amount}. Must be a non-negative integer (lamports).`
231
+ );
232
+ }
233
+ assertExpiry(paymentRequest.created_at, paymentRequest.expiry_secs);
234
+ if (paymentRequest.fee_address && paymentRequest.fee_address !== PROTOCOL_TREASURY) {
235
+ throw new Error(
236
+ `Invalid fee address: expected ${PROTOCOL_TREASURY}, got ${paymentRequest.fee_address}. Cannot build transaction with redirected fees.`
237
+ );
238
+ }
239
+ const payerPubkey = new PublicKey(payerAddress);
240
+ const recipient = new PublicKey(paymentRequest.recipient);
241
+ const reference = new PublicKey(paymentRequest.reference);
242
+ const feeAddress = paymentRequest.fee_address ? new PublicKey(paymentRequest.fee_address) : null;
243
+ const feeAmount = paymentRequest.fee_amount ?? 0;
244
+ const providerAmount = feeAddress && feeAmount > 0 ? paymentRequest.amount - feeAmount : paymentRequest.amount;
245
+ if (providerAmount <= 0) {
246
+ throw new Error(
247
+ `Fee amount (${feeAmount}) exceeds or equals total amount (${paymentRequest.amount}). Cannot create transaction with non-positive provider amount.`
248
+ );
249
+ }
250
+ const transferIx = SystemProgram.transfer({
251
+ fromPubkey: payerPubkey,
252
+ toPubkey: recipient,
253
+ lamports: providerAmount
254
+ });
255
+ transferIx.keys.push({
256
+ pubkey: reference,
257
+ isSigner: false,
258
+ isWritable: false
259
+ });
260
+ const tx = new Transaction().add(transferIx);
261
+ if (feeAddress && feeAmount > 0) {
262
+ tx.add(
263
+ SystemProgram.transfer({
264
+ fromPubkey: payerPubkey,
265
+ toPubkey: feeAddress,
266
+ lamports: feeAmount
175
267
  })
176
- ]);
177
- return true;
178
- } catch {
179
- return false;
180
- } finally {
181
- clearTimeout(timer);
268
+ );
182
269
  }
270
+ return tx;
183
271
  }
184
- getRelays() {
185
- return this.relays;
186
- }
187
- close() {
188
- this.pool.close(this.relays);
189
- }
190
- };
191
- var ElisymIdentity = class _ElisymIdentity {
192
- secretKey;
193
- publicKey;
194
- npub;
195
- constructor(secretKey) {
196
- this.secretKey = secretKey;
197
- this.publicKey = getPublicKey(secretKey);
198
- this.npub = nip19.npubEncode(this.publicKey);
199
- }
200
- static generate() {
201
- return new _ElisymIdentity(generateSecretKey());
202
- }
203
- static fromSecretKey(sk) {
204
- return new _ElisymIdentity(sk);
272
+ async verifyPayment(connection, paymentRequest, options) {
273
+ if (!connection || typeof connection.getTransaction !== "function") {
274
+ return { verified: false, error: "Invalid connection: expected Solana Connection instance" };
275
+ }
276
+ const conn = connection;
277
+ if (!paymentRequest.reference || !paymentRequest.recipient) {
278
+ return { verified: false, error: "Missing required fields in payment request" };
279
+ }
280
+ if (!Number.isInteger(paymentRequest.amount) || paymentRequest.amount <= 0) {
281
+ return {
282
+ verified: false,
283
+ error: `Invalid payment amount: ${paymentRequest.amount}. Must be a positive integer.`
284
+ };
285
+ }
286
+ if (paymentRequest.fee_amount !== null && paymentRequest.fee_amount !== void 0 && (!Number.isInteger(paymentRequest.fee_amount) || paymentRequest.fee_amount < 0)) {
287
+ return {
288
+ verified: false,
289
+ error: `Invalid fee_amount: ${paymentRequest.fee_amount}. Must be a non-negative integer.`
290
+ };
291
+ }
292
+ const expectedFee = calculateProtocolFee(paymentRequest.amount);
293
+ const feeAmount = paymentRequest.fee_amount ?? 0;
294
+ if (expectedFee > 0) {
295
+ if (feeAmount < expectedFee) {
296
+ return {
297
+ verified: false,
298
+ error: `Protocol fee ${feeAmount} below required ${expectedFee} (${PROTOCOL_FEE_BPS}bps of ${paymentRequest.amount})`
299
+ };
300
+ }
301
+ if (!paymentRequest.fee_address) {
302
+ return { verified: false, error: "Missing fee address in payment request" };
303
+ }
304
+ if (paymentRequest.fee_address !== PROTOCOL_TREASURY) {
305
+ return { verified: false, error: `Invalid fee address: ${paymentRequest.fee_address}` };
306
+ }
307
+ }
308
+ const expectedNet = paymentRequest.amount - feeAmount;
309
+ if (expectedNet <= 0) {
310
+ return {
311
+ verified: false,
312
+ error: `Fee amount (${feeAmount}) exceeds or equals total amount (${paymentRequest.amount})`
313
+ };
314
+ }
315
+ if (options?.txSignature) {
316
+ return this._verifyBySignature(
317
+ conn,
318
+ options.txSignature,
319
+ paymentRequest.reference,
320
+ paymentRequest.recipient,
321
+ expectedNet,
322
+ feeAmount,
323
+ options?.retries ?? DEFAULTS.VERIFY_RETRIES,
324
+ options?.intervalMs ?? DEFAULTS.VERIFY_INTERVAL_MS
325
+ );
326
+ }
327
+ return this._verifyByReference(
328
+ conn,
329
+ paymentRequest.reference,
330
+ paymentRequest.recipient,
331
+ expectedNet,
332
+ feeAmount,
333
+ options?.retries ?? DEFAULTS.VERIFY_BY_REF_RETRIES,
334
+ options?.intervalMs ?? DEFAULTS.VERIFY_BY_REF_INTERVAL_MS
335
+ );
205
336
  }
206
- static fromHex(hex) {
207
- if (hex.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(hex)) {
208
- throw new Error("Invalid secret key hex: expected 64 hex characters (32 bytes).");
337
+ async _verifyBySignature(connection, txSignature, referenceKey, recipientAddress, expectedNet, expectedFee, retries, intervalMs) {
338
+ let lastError;
339
+ for (let attempt = 0; attempt < retries; attempt++) {
340
+ try {
341
+ const tx = await connection.getTransaction(txSignature, {
342
+ maxSupportedTransactionVersion: 0,
343
+ commitment: "confirmed"
344
+ });
345
+ if (!tx?.meta || tx.meta.err) {
346
+ if (attempt < retries - 1) {
347
+ await new Promise((r) => setTimeout(r, intervalMs));
348
+ continue;
349
+ }
350
+ return {
351
+ verified: false,
352
+ error: tx?.meta?.err ? "Transaction failed on-chain" : "Transaction not found"
353
+ };
354
+ }
355
+ const accountKeys = tx.transaction.message.getAccountKeys();
356
+ const balanceCount = tx.meta.preBalances.length;
357
+ const keyToIdx = /* @__PURE__ */ new Map();
358
+ for (let i = 0; i < Math.min(accountKeys.length, balanceCount); i++) {
359
+ const key = accountKeys.get(i);
360
+ if (key) {
361
+ keyToIdx.set(key.toBase58(), i);
362
+ }
363
+ }
364
+ if (!keyToIdx.has(referenceKey)) {
365
+ return {
366
+ verified: false,
367
+ error: "Reference key not found in transaction - possible replay"
368
+ };
369
+ }
370
+ const recipientIdx = keyToIdx.get(recipientAddress);
371
+ if (recipientIdx === void 0) {
372
+ return { verified: false, error: "Recipient not found in transaction" };
373
+ }
374
+ const recipientDelta = (tx.meta.postBalances[recipientIdx] ?? 0) - (tx.meta.preBalances[recipientIdx] ?? 0);
375
+ if (recipientDelta < expectedNet) {
376
+ return {
377
+ verified: false,
378
+ error: `Recipient received ${recipientDelta}, expected >= ${expectedNet}`
379
+ };
380
+ }
381
+ if (expectedFee > 0) {
382
+ const treasuryIdx = keyToIdx.get(PROTOCOL_TREASURY);
383
+ if (treasuryIdx === void 0) {
384
+ return { verified: false, error: "Treasury not found in transaction" };
385
+ }
386
+ const treasuryDelta = (tx.meta.postBalances[treasuryIdx] ?? 0) - (tx.meta.preBalances[treasuryIdx] ?? 0);
387
+ if (treasuryDelta < expectedFee) {
388
+ return {
389
+ verified: false,
390
+ error: `Treasury received ${treasuryDelta}, expected >= ${expectedFee}`
391
+ };
392
+ }
393
+ }
394
+ return { verified: true, txSignature };
395
+ } catch (err) {
396
+ lastError = err;
397
+ if (attempt < retries - 1) {
398
+ await new Promise((r) => setTimeout(r, intervalMs));
399
+ }
400
+ }
209
401
  }
210
- const bytes = new Uint8Array(32);
211
- for (let i = 0; i < 64; i += 2) {
212
- bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
402
+ return {
403
+ verified: false,
404
+ error: `Verification failed after ${retries} retries: ${lastError instanceof Error ? lastError.message : "unknown error"}`
405
+ };
406
+ }
407
+ async _verifyByReference(connection, referenceKey, recipientAddress, expectedNet, expectedFee, retries, intervalMs) {
408
+ const reference = new PublicKey(referenceKey);
409
+ let lastError;
410
+ for (let attempt = 0; attempt < retries; attempt++) {
411
+ try {
412
+ const signatures = await connection.getSignaturesForAddress(reference, {
413
+ limit: DEFAULTS.VERIFY_SIGNATURE_LIMIT
414
+ });
415
+ const validSigs = signatures.filter((s) => !s.err);
416
+ if (validSigs.length > 0) {
417
+ const txResults = await Promise.all(
418
+ validSigs.map(
419
+ (s) => connection.getTransaction(s.signature, {
420
+ maxSupportedTransactionVersion: 0,
421
+ commitment: "confirmed"
422
+ }).then((tx) => ({ sig: s.signature, tx })).catch(() => ({
423
+ sig: s.signature,
424
+ tx: null
425
+ }))
426
+ )
427
+ );
428
+ for (const { sig, tx } of txResults) {
429
+ if (!tx?.meta || tx.meta.err) {
430
+ continue;
431
+ }
432
+ const accountKeys = tx.transaction.message.getAccountKeys();
433
+ const balanceCount = tx.meta.preBalances.length;
434
+ const keyToIdx = /* @__PURE__ */ new Map();
435
+ for (let i = 0; i < Math.min(accountKeys.length, balanceCount); i++) {
436
+ const key = accountKeys.get(i);
437
+ if (key) {
438
+ keyToIdx.set(key.toBase58(), i);
439
+ }
440
+ }
441
+ const recipientIdx = keyToIdx.get(recipientAddress);
442
+ if (recipientIdx === void 0) {
443
+ continue;
444
+ }
445
+ const recipientDelta = (tx.meta.postBalances[recipientIdx] ?? 0) - (tx.meta.preBalances[recipientIdx] ?? 0);
446
+ if (recipientDelta < expectedNet) {
447
+ continue;
448
+ }
449
+ if (expectedFee > 0) {
450
+ const treasuryIdx = keyToIdx.get(PROTOCOL_TREASURY);
451
+ if (treasuryIdx === void 0) {
452
+ continue;
453
+ }
454
+ const treasuryDelta = (tx.meta.postBalances[treasuryIdx] ?? 0) - (tx.meta.preBalances[treasuryIdx] ?? 0);
455
+ if (treasuryDelta < expectedFee) {
456
+ continue;
457
+ }
458
+ }
459
+ return { verified: true, txSignature: sig };
460
+ }
461
+ }
462
+ } catch (err) {
463
+ lastError = err;
464
+ }
465
+ if (attempt < retries - 1) {
466
+ await new Promise((r) => setTimeout(r, intervalMs));
467
+ }
213
468
  }
214
- return new _ElisymIdentity(bytes);
469
+ return {
470
+ verified: false,
471
+ error: lastError ? `Verification failed: ${lastError instanceof Error ? lastError.message : "unknown error"}` : "No matching transaction found for reference key"
472
+ };
215
473
  }
216
474
  };
217
475
  function toDTag(name) {
218
- return name.toLowerCase().replace(/\s+/g, "-");
476
+ const tag = name.toLowerCase().replace(/[^a-z0-9\s-]/g, (ch) => "_" + ch.charCodeAt(0).toString(16).padStart(2, "0")).replace(/\s+/g, "-").replace(/^-+|-+$/g, "");
477
+ if (!tag) {
478
+ throw new Error("Capability name must contain at least one ASCII alphanumeric character.");
479
+ }
480
+ return tag;
219
481
  }
220
482
  function buildAgentsFromEvents(events, network) {
221
483
  const latestByDTag = /* @__PURE__ */ new Map();
222
484
  for (const event of events) {
485
+ if (!verifyEvent(event)) {
486
+ continue;
487
+ }
223
488
  const dTag = event.tags.find((t) => t[0] === "d")?.[1] ?? "";
224
489
  const key = `${event.pubkey}:${dTag}`;
225
490
  const prev = latestByDTag.get(key);
@@ -230,17 +495,45 @@ function buildAgentsFromEvents(events, network) {
230
495
  const accumMap = /* @__PURE__ */ new Map();
231
496
  for (const event of latestByDTag.values()) {
232
497
  try {
233
- const card = JSON.parse(event.content);
234
- if (!card.name || card.deleted) continue;
498
+ if (!event.content) {
499
+ continue;
500
+ }
501
+ const raw = JSON.parse(event.content);
502
+ if (!raw || typeof raw !== "object") {
503
+ continue;
504
+ }
505
+ if (typeof raw.name !== "string" || !raw.name) {
506
+ continue;
507
+ }
508
+ if (typeof raw.description !== "string") {
509
+ continue;
510
+ }
511
+ if (!Array.isArray(raw.capabilities) || !raw.capabilities.every((c) => typeof c === "string")) {
512
+ continue;
513
+ }
514
+ if (raw.deleted) {
515
+ continue;
516
+ }
517
+ const card = raw;
518
+ if (card.payment && (typeof card.payment.chain !== "string" || typeof card.payment.network !== "string" || typeof card.payment.address !== "string")) {
519
+ continue;
520
+ }
521
+ if (card.payment?.job_price !== null && card.payment?.job_price !== void 0 && (!Number.isInteger(card.payment.job_price) || card.payment.job_price < 0)) {
522
+ continue;
523
+ }
235
524
  const agentNetwork = card.payment?.network ?? "devnet";
236
- if (agentNetwork !== network) continue;
525
+ if (agentNetwork !== network) {
526
+ continue;
527
+ }
237
528
  const kTags = event.tags.filter((t) => t[0] === "k").map((t) => parseInt(t[1] ?? "", 10)).filter((k) => !isNaN(k));
238
529
  const entry = { card, kTags, createdAt: event.created_at };
239
530
  const existing = accumMap.get(event.pubkey);
240
531
  if (existing) {
241
532
  const dupIndex = existing.entries.findIndex((e) => e.card.name === card.name);
242
533
  if (dupIndex >= 0) {
243
- existing.entries[dupIndex] = entry;
534
+ if (entry.createdAt >= existing.entries[dupIndex].createdAt) {
535
+ existing.entries[dupIndex] = entry;
536
+ }
244
537
  } else {
245
538
  existing.entries.push(entry);
246
539
  }
@@ -262,12 +555,13 @@ function buildAgentsFromEvents(events, network) {
262
555
  }
263
556
  const agentMap = /* @__PURE__ */ new Map();
264
557
  for (const [pubkey, acc] of accumMap) {
265
- const supportedKinds = [];
558
+ const kindsSet = /* @__PURE__ */ new Set();
266
559
  for (const e of acc.entries) {
267
560
  for (const k of e.kTags) {
268
- if (!supportedKinds.includes(k)) supportedKinds.push(k);
561
+ kindsSet.add(k);
269
562
  }
270
563
  }
564
+ const supportedKinds = [...kindsSet];
271
565
  agentMap.set(pubkey, {
272
566
  pubkey: acc.pubkey,
273
567
  npub: acc.npub,
@@ -283,23 +577,28 @@ var DiscoveryService = class {
283
577
  constructor(pool) {
284
578
  this.pool = pool;
285
579
  }
286
- // Instance-level set — avoids module-level state leak across clients
287
- allSeenAgents = /* @__PURE__ */ new Set();
288
580
  /** Count elisym agents (kind:31990 with "elisym" tag). */
289
581
  async fetchAllAgentCount() {
290
582
  const events = await this.pool.querySync({
291
583
  kinds: [KIND_APP_HANDLER],
292
584
  "#t": ["elisym"]
293
585
  });
586
+ const uniquePubkeys = /* @__PURE__ */ new Set();
294
587
  for (const event of events) {
295
- this.allSeenAgents.add(event.pubkey);
588
+ if (!verifyEvent(event)) {
589
+ continue;
590
+ }
591
+ uniquePubkeys.add(event.pubkey);
296
592
  }
297
- return this.allSeenAgents.size;
593
+ return uniquePubkeys.size;
298
594
  }
299
595
  /**
300
596
  * Fetch a single page of elisym agents with relay-side pagination.
301
597
  * Uses `until` cursor for Nostr cursor-based pagination.
302
- * Does NOT fetch activity (faster than fetchAgents).
598
+ *
599
+ * Unlike `fetchAgents`, this method does NOT enrich agents with
600
+ * kind:0 metadata (name, picture, about) or update `lastSeen` from
601
+ * recent job activity. Call `enrichWithMetadata()` separately if needed.
303
602
  */
304
603
  async fetchAgentsPage(network = "devnet", limit = 20, until) {
305
604
  const filter = {
@@ -319,21 +618,24 @@ var DiscoveryService = class {
319
618
  }
320
619
  }
321
620
  const agentMap = buildAgentsFromEvents(events, network);
322
- const agents = Array.from(agentMap.values()).sort(
323
- (a, b) => b.lastSeen - a.lastSeen
324
- );
621
+ const agents = Array.from(agentMap.values()).sort((a, b) => b.lastSeen - a.lastSeen);
325
622
  return { agents, oldestCreatedAt, rawEventCount };
326
623
  }
327
624
  /** Enrich agents with kind:0 metadata (name, picture, about). Mutates in place and returns the same array. */
328
625
  async enrichWithMetadata(agents) {
329
626
  const pubkeys = agents.map((a) => a.pubkey);
330
- if (pubkeys.length === 0) return agents;
627
+ if (pubkeys.length === 0) {
628
+ return agents;
629
+ }
331
630
  const metaEvents = await this.pool.queryBatched(
332
631
  { kinds: [0] },
333
632
  pubkeys
334
633
  );
335
634
  const latestMeta = /* @__PURE__ */ new Map();
336
635
  for (const ev of metaEvents) {
636
+ if (!verifyEvent(ev)) {
637
+ continue;
638
+ }
337
639
  const prev = latestMeta.get(ev.pubkey);
338
640
  if (!prev || ev.created_at > prev.created_at) {
339
641
  latestMeta.set(ev.pubkey, ev);
@@ -342,12 +644,20 @@ var DiscoveryService = class {
342
644
  const agentLookup = new Map(agents.map((a) => [a.pubkey, a]));
343
645
  for (const [pubkey, ev] of latestMeta) {
344
646
  const agent = agentLookup.get(pubkey);
345
- if (!agent) continue;
647
+ if (!agent) {
648
+ continue;
649
+ }
346
650
  try {
347
651
  const meta = JSON.parse(ev.content);
348
- if (meta.picture) agent.picture = meta.picture;
349
- if (meta.name) agent.name = meta.name;
350
- if (meta.about) agent.about = meta.about;
652
+ if (typeof meta.picture === "string") {
653
+ agent.picture = meta.picture;
654
+ }
655
+ if (typeof meta.name === "string") {
656
+ agent.name = meta.name;
657
+ }
658
+ if (typeof meta.about === "string") {
659
+ agent.about = meta.about;
660
+ }
351
661
  } catch {
352
662
  }
353
663
  }
@@ -359,9 +669,12 @@ var DiscoveryService = class {
359
669
  kinds: [KIND_APP_HANDLER],
360
670
  "#t": ["elisym"]
361
671
  };
362
- if (limit !== void 0) filter.limit = limit;
672
+ if (limit !== void 0) {
673
+ filter.limit = limit;
674
+ }
363
675
  const events = await this.pool.querySync(filter);
364
676
  const agentMap = buildAgentsFromEvents(events, network);
677
+ const agents = Array.from(agentMap.values()).sort((a, b) => b.lastSeen - a.lastSeen);
365
678
  const agentPubkeys = Array.from(agentMap.keys());
366
679
  if (agentPubkeys.length > 0) {
367
680
  const activitySince = Math.floor(Date.now() / 1e3) - 24 * 60 * 60;
@@ -374,33 +687,65 @@ var DiscoveryService = class {
374
687
  }
375
688
  }
376
689
  resultKinds.add(jobResultKind(DEFAULT_KIND_OFFSET));
377
- const activityEvents = await this.pool.queryBatched(
378
- {
379
- kinds: [...resultKinds, KIND_JOB_FEEDBACK],
380
- since: activitySince
381
- },
382
- agentPubkeys
383
- );
690
+ const [activityEvents] = await Promise.all([
691
+ this.pool.queryBatched(
692
+ {
693
+ kinds: [...resultKinds, KIND_JOB_FEEDBACK],
694
+ since: activitySince
695
+ },
696
+ agentPubkeys
697
+ ),
698
+ this.enrichWithMetadata(agents)
699
+ ]);
384
700
  for (const ev of activityEvents) {
701
+ if (!verifyEvent(ev)) {
702
+ continue;
703
+ }
385
704
  const agent = agentMap.get(ev.pubkey);
386
705
  if (agent && ev.created_at > agent.lastSeen) {
387
706
  agent.lastSeen = ev.created_at;
388
707
  }
389
708
  }
709
+ agents.sort((a, b) => b.lastSeen - a.lastSeen);
390
710
  }
391
- const agents = Array.from(agentMap.values()).sort(
392
- (a, b) => b.lastSeen - a.lastSeen
393
- );
394
- await this.enrichWithMetadata(agents);
395
711
  return agents;
396
712
  }
397
- /** Publish a capability card (kind:31990) as a provider. */
713
+ /**
714
+ * Publish a capability card (kind:31990) as a provider.
715
+ * Solana address is validated for Base58 format only - full decode
716
+ * validation (32-byte public key) happens at payment time.
717
+ */
398
718
  async publishCapability(identity, card, kinds = [KIND_JOB_REQUEST]) {
399
719
  if (!card.payment?.address) {
400
720
  throw new Error(
401
721
  "Cannot publish capability without a payment address. Connect a wallet before publishing."
402
722
  );
403
723
  }
724
+ if (card.payment.chain === "solana" && !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(card.payment.address)) {
725
+ throw new Error(`Invalid Solana address format: ${card.payment.address}`);
726
+ }
727
+ if (card.name.length > LIMITS.MAX_AGENT_NAME_LENGTH) {
728
+ throw new Error(
729
+ `Agent name too long: ${card.name.length} chars (max ${LIMITS.MAX_AGENT_NAME_LENGTH}).`
730
+ );
731
+ }
732
+ if (card.description.length > LIMITS.MAX_DESCRIPTION_LENGTH) {
733
+ throw new Error(
734
+ `Description too long: ${card.description.length} chars (max ${LIMITS.MAX_DESCRIPTION_LENGTH}).`
735
+ );
736
+ }
737
+ if (card.capabilities.length > LIMITS.MAX_CAPABILITIES) {
738
+ throw new Error(
739
+ `Too many capabilities: ${card.capabilities.length} (max ${LIMITS.MAX_CAPABILITIES}).`
740
+ );
741
+ }
742
+ for (const cap of card.capabilities) {
743
+ if (cap.length > LIMITS.MAX_CAPABILITY_LENGTH) {
744
+ throw new Error(
745
+ `Capability name too long: "${cap}" (${cap.length} chars, max ${LIMITS.MAX_CAPABILITY_LENGTH}).`
746
+ );
747
+ }
748
+ }
404
749
  const tags = [
405
750
  ["d", toDTag(card.name)],
406
751
  ["t", "elisym"],
@@ -416,13 +761,28 @@ var DiscoveryService = class {
416
761
  },
417
762
  identity.secretKey
418
763
  );
419
- await this.pool.publish(event);
764
+ await this.pool.publishAll(event);
420
765
  return event.id;
421
766
  }
422
767
  /** Publish a Nostr profile (kind:0) as a provider. */
423
- async publishProfile(identity, name, about, picture) {
768
+ async publishProfile(identity, name, about, picture, banner) {
769
+ if (name.length > LIMITS.MAX_AGENT_NAME_LENGTH) {
770
+ throw new Error(
771
+ `Profile name too long: ${name.length} chars (max ${LIMITS.MAX_AGENT_NAME_LENGTH}).`
772
+ );
773
+ }
774
+ if (about.length > LIMITS.MAX_DESCRIPTION_LENGTH) {
775
+ throw new Error(
776
+ `Profile about too long: ${about.length} chars (max ${LIMITS.MAX_DESCRIPTION_LENGTH}).`
777
+ );
778
+ }
424
779
  const content = { name, about };
425
- if (picture) content.picture = picture;
780
+ if (picture) {
781
+ content.picture = picture;
782
+ }
783
+ if (banner) {
784
+ content.banner = banner;
785
+ }
426
786
  const event = finalizeEvent(
427
787
  {
428
788
  kind: 0,
@@ -432,7 +792,7 @@ var DiscoveryService = class {
432
792
  },
433
793
  identity.secretKey
434
794
  );
435
- await this.pool.publish(event);
795
+ await this.pool.publishAll(event);
436
796
  return event.id;
437
797
  }
438
798
  /**
@@ -459,20 +819,40 @@ var DiscoveryService = class {
459
819
  return event.id;
460
820
  }
461
821
  };
462
- function isEncrypted(event) {
463
- return event.tags.some((t) => t[0] === "encrypted" && t[1] === "nip44");
464
- }
465
- function nip44Encrypt(plaintext, secretKey, recipientPubkey) {
466
- const conversationKey = nip44.v2.utils.getConversationKey(secretKey, recipientPubkey);
822
+ function nip44Encrypt(plaintext, senderSk, recipientPubkey) {
823
+ const conversationKey = nip44.v2.utils.getConversationKey(senderSk, recipientPubkey);
467
824
  return nip44.v2.encrypt(plaintext, conversationKey);
468
825
  }
469
- function nip44Decrypt(ciphertext, secretKey, senderPubkey) {
470
- const conversationKey = nip44.v2.utils.getConversationKey(secretKey, senderPubkey);
826
+ function nip44Decrypt(ciphertext, receiverSk, senderPubkey) {
827
+ const conversationKey = nip44.v2.utils.getConversationKey(receiverSk, senderPubkey);
471
828
  return nip44.v2.decrypt(ciphertext, conversationKey);
472
829
  }
830
+
831
+ // src/services/marketplace.ts
832
+ function isEncrypted(event) {
833
+ return event.tags.some((t) => t[0] === "encrypted" && t[1] === "nip44");
834
+ }
473
835
  function resolveRequestId(event) {
474
836
  return event.tags.find((t) => t[0] === "e")?.[1];
475
837
  }
838
+ function safeParseInt(value) {
839
+ if (!value) {
840
+ return void 0;
841
+ }
842
+ const n = parseInt(value, 10);
843
+ return isNaN(n) ? void 0 : n;
844
+ }
845
+ var VALID_JOB_STATUSES = /* @__PURE__ */ new Set([
846
+ "payment-required",
847
+ "payment-completed",
848
+ "processing",
849
+ "error",
850
+ "success",
851
+ "partial"
852
+ ]);
853
+ function toJobStatus(raw) {
854
+ return VALID_JOB_STATUSES.has(raw) ? raw : "unknown";
855
+ }
476
856
  var MarketplaceService = class {
477
857
  constructor(pool) {
478
858
  this.pool = pool;
@@ -482,11 +862,21 @@ var MarketplaceService = class {
482
862
  if (!options.input) {
483
863
  throw new Error("Job input must not be empty.");
484
864
  }
865
+ if (options.input.length > LIMITS.MAX_INPUT_LENGTH) {
866
+ throw new Error(
867
+ `Job input too long: ${options.input.length} chars (max ${LIMITS.MAX_INPUT_LENGTH}).`
868
+ );
869
+ }
870
+ if (!options.capability || options.capability.length > LIMITS.MAX_CAPABILITY_LENGTH) {
871
+ throw new Error(`Invalid capability: must be 1-${LIMITS.MAX_CAPABILITY_LENGTH} characters.`);
872
+ }
873
+ if (options.providerPubkey && !/^[0-9a-f]{64}$/.test(options.providerPubkey)) {
874
+ throw new Error("Invalid provider pubkey: expected 64 hex characters.");
875
+ }
485
876
  const plaintext = options.input;
486
877
  const encrypted = options.providerPubkey ? nip44Encrypt(plaintext, identity.secretKey, options.providerPubkey) : plaintext;
487
- const iValue = options.providerPubkey ? "encrypted" : "";
488
878
  const tags = [
489
- ["i", iValue, "text"],
879
+ ["i", options.providerPubkey ? "encrypted" : "text", "text"],
490
880
  ["t", options.capability],
491
881
  ["t", "elisym"],
492
882
  ["output", "text/plain"]
@@ -510,80 +900,148 @@ var MarketplaceService = class {
510
900
  }
511
901
  /**
512
902
  * Subscribe to job updates (feedback + results) for a given job.
513
- * Returns a cleanup function.
903
+ * Creates 3 subscriptions per call (feedback, result by #e, result by #p+#e)
904
+ * to cover different relay indexing strategies. Returns a cleanup function.
514
905
  */
515
- subscribeToJobUpdates(jobEventId, providerPubkey, customerPublicKey, callbacks, timeoutMs = 12e4, customerSecretKey, kindOffsets) {
516
- const offsets = kindOffsets ?? [DEFAULT_KIND_OFFSET];
906
+ subscribeToJobUpdates(options) {
907
+ const {
908
+ jobEventId: jid,
909
+ providerPubkey: provPk,
910
+ customerPublicKey: custPk,
911
+ callbacks: cb,
912
+ timeoutMs = DEFAULTS.SUBSCRIPTION_TIMEOUT_MS,
913
+ customerSecretKey: custSk,
914
+ kindOffsets: offsets_,
915
+ sinceOverride: since_
916
+ } = options;
917
+ const offsets = offsets_ ?? [DEFAULT_KIND_OFFSET];
918
+ if (offsets.length === 0) {
919
+ throw new Error("kindOffsets must not be empty.");
920
+ }
517
921
  const resultKinds = offsets.map(jobResultKind);
518
- const since = Math.floor(Date.now() / 1e3) - 5;
922
+ const since = since_ ?? Math.floor(Date.now() / 1e3) - 30;
519
923
  const subs = [];
520
924
  let resolved = false;
521
925
  let resultDelivered = false;
522
926
  let timer;
523
927
  const done = () => {
524
928
  resolved = true;
525
- if (timer) clearTimeout(timer);
526
- for (const s of subs) s.close();
929
+ if (timer) {
930
+ clearTimeout(timer);
931
+ }
932
+ for (const s of subs) {
933
+ try {
934
+ s.close();
935
+ } catch {
936
+ }
937
+ }
527
938
  };
528
939
  const decryptResult = (ev) => {
529
- if (customerSecretKey && isEncrypted(ev)) {
940
+ if (isEncrypted(ev)) {
941
+ if (!custSk) {
942
+ return null;
943
+ }
530
944
  try {
531
- return nip44Decrypt(ev.content, customerSecretKey, ev.pubkey);
945
+ return nip44Decrypt(ev.content, custSk, ev.pubkey);
532
946
  } catch {
533
- return ev.content;
947
+ return null;
534
948
  }
535
949
  }
536
950
  return ev.content;
537
951
  };
538
952
  const handleResult = (ev) => {
539
- if (resolved || resultDelivered) return;
540
- if (providerPubkey && ev.pubkey !== providerPubkey) return;
953
+ if (resolved || resultDelivered) {
954
+ return;
955
+ }
956
+ if (!verifyEvent(ev)) {
957
+ return;
958
+ }
959
+ if (provPk && ev.pubkey !== provPk) {
960
+ return;
961
+ }
962
+ const eTag = ev.tags.find((t) => t[0] === "e")?.[1];
963
+ if (eTag !== jid) {
964
+ return;
965
+ }
966
+ const content = decryptResult(ev);
967
+ if (content === null) {
968
+ return;
969
+ }
541
970
  resultDelivered = true;
542
- callbacks.onResult?.(decryptResult(ev), ev.id);
543
- done();
544
- };
545
- const feedbackSub = this.pool.subscribe(
546
- {
547
- kinds: [KIND_JOB_FEEDBACK],
548
- "#e": [jobEventId],
549
- since
550
- },
551
- (ev) => {
552
- if (resolved) return;
553
- if (providerPubkey && ev.pubkey !== providerPubkey) return;
554
- const statusTag = ev.tags.find((t) => t[0] === "status");
555
- if (statusTag?.[1] === "payment-required") {
556
- const amtTag = ev.tags.find((t) => t[0] === "amount");
557
- const amt = amtTag?.[1] ? parseInt(amtTag[1], 10) : 0;
558
- const paymentReq = amtTag?.[2];
559
- callbacks.onFeedback?.("payment-required", amt, paymentReq);
560
- }
971
+ try {
972
+ cb.onResult?.(content, ev.id);
973
+ } catch {
974
+ } finally {
975
+ done();
561
976
  }
562
- );
563
- subs.push(feedbackSub);
564
- const resultSub = this.pool.subscribe(
565
- {
566
- kinds: resultKinds,
567
- "#e": [jobEventId],
568
- since
569
- },
570
- handleResult
571
- );
572
- subs.push(resultSub);
573
- const resultSub2 = this.pool.subscribe(
574
- {
575
- kinds: resultKinds,
576
- "#p": [customerPublicKey],
577
- "#e": [jobEventId],
578
- since
579
- },
580
- handleResult
581
- );
582
- subs.push(resultSub2);
977
+ };
978
+ try {
979
+ subs.push(
980
+ this.pool.subscribe(
981
+ {
982
+ kinds: [KIND_JOB_FEEDBACK],
983
+ "#e": [jid],
984
+ since
985
+ },
986
+ (ev) => {
987
+ if (resolved) {
988
+ return;
989
+ }
990
+ if (!verifyEvent(ev)) {
991
+ return;
992
+ }
993
+ if (provPk && ev.pubkey !== provPk) {
994
+ return;
995
+ }
996
+ const eTag = ev.tags.find((t) => t[0] === "e")?.[1];
997
+ if (eTag !== jid) {
998
+ return;
999
+ }
1000
+ const statusTag = ev.tags.find((t) => t[0] === "status");
1001
+ if (statusTag?.[1]) {
1002
+ const amtTag = ev.tags.find((t) => t[0] === "amount");
1003
+ const amt = safeParseInt(amtTag?.[1]) ?? 0;
1004
+ const paymentReq = amtTag?.[2];
1005
+ try {
1006
+ cb.onFeedback?.(statusTag[1], amt, paymentReq, ev.pubkey);
1007
+ } catch {
1008
+ }
1009
+ }
1010
+ }
1011
+ )
1012
+ );
1013
+ subs.push(
1014
+ this.pool.subscribe(
1015
+ {
1016
+ kinds: resultKinds,
1017
+ "#e": [jid],
1018
+ since
1019
+ },
1020
+ handleResult
1021
+ )
1022
+ );
1023
+ subs.push(
1024
+ this.pool.subscribe(
1025
+ {
1026
+ kinds: resultKinds,
1027
+ "#p": [custPk],
1028
+ "#e": [jid],
1029
+ since
1030
+ },
1031
+ handleResult
1032
+ )
1033
+ );
1034
+ } catch (err) {
1035
+ done();
1036
+ throw err;
1037
+ }
583
1038
  timer = setTimeout(() => {
584
1039
  if (!resolved) {
585
1040
  done();
586
- callbacks.onError?.(`Timed out waiting for response (${timeoutMs / 1e3}s).`);
1041
+ try {
1042
+ cb.onError?.(`Timed out waiting for response (${timeoutMs / 1e3}s).`);
1043
+ } catch {
1044
+ }
587
1045
  }
588
1046
  }, timeoutMs);
589
1047
  return done;
@@ -605,7 +1063,7 @@ var MarketplaceService = class {
605
1063
  },
606
1064
  identity.secretKey
607
1065
  );
608
- await this.pool.publish(event);
1066
+ await this.pool.publishAll(event);
609
1067
  }
610
1068
  /** Submit rating feedback for a job. */
611
1069
  async submitFeedback(identity, jobEventId, providerPubkey, positive, capability) {
@@ -616,7 +1074,9 @@ var MarketplaceService = class {
616
1074
  ["rating", positive ? "1" : "0"],
617
1075
  ["t", "elisym"]
618
1076
  ];
619
- if (capability) tags.push(["t", capability]);
1077
+ if (capability) {
1078
+ tags.push(["t", capability]);
1079
+ }
620
1080
  const event = finalizeEvent(
621
1081
  {
622
1082
  kind: KIND_JOB_FEEDBACK,
@@ -626,31 +1086,67 @@ var MarketplaceService = class {
626
1086
  },
627
1087
  identity.secretKey
628
1088
  );
629
- await this.pool.publish(event);
1089
+ await this.pool.publishAll(event);
630
1090
  }
631
1091
  // --- Provider methods ---
632
- /** Subscribe to incoming job requests for specific kinds. */
1092
+ /**
1093
+ * Subscribe to incoming job requests for specific kinds.
1094
+ * Automatically decrypts NIP-44 encrypted content.
1095
+ * Note: decrypted events have modified `content` - do not call `verifyEvent()` on them.
1096
+ * Signature verification is performed before decryption.
1097
+ */
633
1098
  subscribeToJobRequests(identity, kinds, onRequest) {
634
1099
  return this.pool.subscribe(
635
1100
  {
636
1101
  kinds,
637
1102
  "#p": [identity.publicKey],
638
- since: Math.floor(Date.now() / 1e3)
1103
+ "#t": ["elisym"],
1104
+ since: Math.floor(Date.now() / 1e3) - 5
639
1105
  },
640
- onRequest
1106
+ (event) => {
1107
+ if (!verifyEvent(event)) {
1108
+ return;
1109
+ }
1110
+ if (isEncrypted(event) && event.content) {
1111
+ try {
1112
+ const decrypted = nip44Decrypt(event.content, identity.secretKey, event.pubkey);
1113
+ onRequest({ ...event, content: decrypted });
1114
+ } catch {
1115
+ return;
1116
+ }
1117
+ } else {
1118
+ onRequest(event);
1119
+ }
1120
+ }
641
1121
  );
642
1122
  }
643
1123
  /** Submit a job result with NIP-44 encrypted content. Result kind is derived from the request kind. */
644
1124
  async submitJobResult(identity, requestEvent, content, amount) {
645
- const encrypted = nip44Encrypt(content, identity.secretKey, requestEvent.pubkey);
646
- const resultKind = KIND_JOB_RESULT_BASE + (requestEvent.kind - KIND_JOB_REQUEST_BASE);
1125
+ if (!content) {
1126
+ throw new Error("Job result content must not be empty.");
1127
+ }
1128
+ if (!Number.isInteger(requestEvent.kind)) {
1129
+ throw new Error(`Invalid request event kind: expected integer, got ${requestEvent.kind}.`);
1130
+ }
1131
+ const offset = requestEvent.kind - KIND_JOB_REQUEST_BASE;
1132
+ if (offset < 0 || offset >= 1e3) {
1133
+ throw new Error(
1134
+ `Invalid request event kind ${requestEvent.kind}: expected a NIP-90 job request kind (5000-5999).`
1135
+ );
1136
+ }
1137
+ const shouldEncrypt = isEncrypted(requestEvent);
1138
+ const resultContent = shouldEncrypt ? nip44Encrypt(content, identity.secretKey, requestEvent.pubkey) : content;
1139
+ const resultKind = KIND_JOB_RESULT_BASE + offset;
647
1140
  const tags = [
648
1141
  ["e", requestEvent.id],
649
1142
  ["p", requestEvent.pubkey],
650
- ["t", "elisym"],
651
- ["encrypted", "nip44"]
1143
+ ["t", "elisym"]
652
1144
  ];
653
- if (amount != null) {
1145
+ if (shouldEncrypt) {
1146
+ tags.push(["encrypted", "nip44"]);
1147
+ }
1148
+ if (amount !== null && amount !== void 0) {
1149
+ assertLamports(amount, "result amount");
654
1150
  tags.push(["amount", String(amount)]);
655
1151
  }
656
1152
  const event = finalizeEvent(
@@ -658,15 +1154,50 @@ var MarketplaceService = class {
658
1154
  kind: resultKind,
659
1155
  created_at: Math.floor(Date.now() / 1e3),
660
1156
  tags,
661
- content: encrypted
1157
+ content: resultContent
662
1158
  },
663
1159
  identity.secretKey
664
1160
  );
665
- await this.pool.publish(event);
1161
+ await this.pool.publishAll(event);
666
1162
  return event.id;
667
1163
  }
1164
+ /**
1165
+ * Submit a job result with retry and exponential backoff.
1166
+ * Retries on publish failures (e.g. relay disconnects).
1167
+ * With maxAttempts=3: try, ~1s, try, ~2s, try, throw.
1168
+ * Jitter: 0.5x-1.0x of calculated delay.
1169
+ */
1170
+ async submitJobResultWithRetry(identity, requestEvent, content, amount, maxAttempts = DEFAULTS.RESULT_RETRY_COUNT, baseDelayMs = DEFAULTS.RESULT_RETRY_BASE_MS) {
1171
+ const attempts = Math.max(1, maxAttempts);
1172
+ for (let attempt = 0; attempt < attempts; attempt++) {
1173
+ try {
1174
+ return await this.submitJobResult(identity, requestEvent, content, amount);
1175
+ } catch (e) {
1176
+ if (attempt >= attempts - 1) {
1177
+ throw e;
1178
+ }
1179
+ const jitter = 0.5 + Math.random() * 0.5;
1180
+ await new Promise((r) => setTimeout(r, baseDelayMs * Math.pow(2, attempt) * jitter));
1181
+ }
1182
+ }
1183
+ throw new Error("All delivery attempts failed");
1184
+ }
668
1185
  /** Submit payment-required feedback with a payment request. */
669
1186
  async submitPaymentRequiredFeedback(identity, requestEvent, amount, paymentRequestJson) {
1187
+ assertLamports(amount, "payment amount");
1188
+ if (amount === 0) {
1189
+ throw new Error("Invalid payment amount: 0. Must be positive.");
1190
+ }
1191
+ try {
1192
+ JSON.parse(paymentRequestJson);
1193
+ } catch {
1194
+ throw new Error("Invalid paymentRequestJson: must be valid JSON.");
1195
+ }
1196
+ if (paymentRequestJson.length > LIMITS.MAX_INPUT_LENGTH) {
1197
+ throw new Error(
1198
+ `paymentRequestJson too long: ${paymentRequestJson.length} chars (max ${LIMITS.MAX_INPUT_LENGTH}).`
1199
+ );
1200
+ }
670
1201
  const event = finalizeEvent(
671
1202
  {
672
1203
  kind: KIND_JOB_FEEDBACK,
@@ -682,74 +1213,193 @@ var MarketplaceService = class {
682
1213
  },
683
1214
  identity.secretKey
684
1215
  );
685
- await this.pool.publish(event);
1216
+ await this.pool.publishAll(event);
1217
+ }
1218
+ /** Submit processing feedback to notify customer that work has started. */
1219
+ async submitProcessingFeedback(identity, requestEvent) {
1220
+ const event = finalizeEvent(
1221
+ {
1222
+ kind: KIND_JOB_FEEDBACK,
1223
+ created_at: Math.floor(Date.now() / 1e3),
1224
+ tags: [
1225
+ ["e", requestEvent.id],
1226
+ ["p", requestEvent.pubkey],
1227
+ ["status", "processing"],
1228
+ ["t", "elisym"]
1229
+ ],
1230
+ content: ""
1231
+ },
1232
+ identity.secretKey
1233
+ );
1234
+ await this.pool.publishAll(event);
1235
+ }
1236
+ /** Submit error feedback to notify customer of a failure. */
1237
+ async submitErrorFeedback(identity, requestEvent, message) {
1238
+ if (!message) {
1239
+ throw new Error("Error message must not be empty.");
1240
+ }
1241
+ if (message.length > LIMITS.MAX_INPUT_LENGTH) {
1242
+ throw new Error(
1243
+ `Error message too long: ${message.length} chars (max ${LIMITS.MAX_INPUT_LENGTH}).`
1244
+ );
1245
+ }
1246
+ const event = finalizeEvent(
1247
+ {
1248
+ kind: KIND_JOB_FEEDBACK,
1249
+ created_at: Math.floor(Date.now() / 1e3),
1250
+ tags: [
1251
+ ["e", requestEvent.id],
1252
+ ["p", requestEvent.pubkey],
1253
+ ["status", "error"],
1254
+ ["t", "elisym"]
1255
+ ],
1256
+ content: message
1257
+ },
1258
+ identity.secretKey
1259
+ );
1260
+ await this.pool.publishAll(event);
1261
+ }
1262
+ /** Query job results by request IDs and decrypt NIP-44 content. */
1263
+ async queryJobResults(identity, requestIds, kindOffsets, providerPubkey) {
1264
+ const offsets = kindOffsets ?? [DEFAULT_KIND_OFFSET];
1265
+ if (offsets.length === 0) {
1266
+ throw new Error("kindOffsets must not be empty.");
1267
+ }
1268
+ const resultKinds = offsets.map(jobResultKind);
1269
+ const results = await this.pool.queryBatchedByTag(
1270
+ { kinds: resultKinds },
1271
+ "e",
1272
+ requestIds
1273
+ );
1274
+ const resultByRequest = /* @__PURE__ */ new Map();
1275
+ const createdAtByRequest = /* @__PURE__ */ new Map();
1276
+ for (const r of results) {
1277
+ if (!verifyEvent(r)) {
1278
+ continue;
1279
+ }
1280
+ if (providerPubkey && r.pubkey !== providerPubkey) {
1281
+ continue;
1282
+ }
1283
+ const eTag = r.tags.find((t) => t[0] === "e");
1284
+ if (!eTag?.[1]) {
1285
+ continue;
1286
+ }
1287
+ const prevTs = createdAtByRequest.get(eTag[1]) ?? 0;
1288
+ if (r.created_at < prevTs) {
1289
+ continue;
1290
+ }
1291
+ const amtTag = r.tags.find((t) => t[0] === "amount");
1292
+ let content = r.content;
1293
+ let decryptionFailed = false;
1294
+ if (isEncrypted(r)) {
1295
+ try {
1296
+ content = nip44Decrypt(r.content, identity.secretKey, r.pubkey);
1297
+ } catch {
1298
+ content = "";
1299
+ decryptionFailed = true;
1300
+ }
1301
+ }
1302
+ createdAtByRequest.set(eTag[1], r.created_at);
1303
+ resultByRequest.set(eTag[1], {
1304
+ content,
1305
+ amount: safeParseInt(amtTag?.[1]),
1306
+ senderPubkey: r.pubkey,
1307
+ decryptionFailed
1308
+ });
1309
+ }
1310
+ return resultByRequest;
686
1311
  }
687
1312
  // --- Query methods ---
688
- /** Fetch recent jobs from the network. */
1313
+ /**
1314
+ * Fetch recent jobs from the network.
1315
+ * NOTE: Job.result contains raw event content. For encrypted jobs,
1316
+ * this will be NIP-44 ciphertext - use queryJobResults() for decryption.
1317
+ */
689
1318
  async fetchRecentJobs(agentPubkeys, limit, since, kindOffsets) {
690
1319
  const offsets = kindOffsets ?? [DEFAULT_KIND_OFFSET];
1320
+ if (offsets.length === 0) {
1321
+ throw new Error("kindOffsets must not be empty.");
1322
+ }
691
1323
  const requestKinds = offsets.map(jobRequestKind);
692
1324
  const resultKinds = offsets.map(jobResultKind);
693
1325
  const reqFilter = {
694
1326
  kinds: requestKinds,
695
1327
  "#t": ["elisym"],
696
- ...limit != null && { limit },
697
- ...since != null && { since }
1328
+ ...limit !== null && limit !== void 0 && { limit },
1329
+ ...since !== null && since !== void 0 && { since }
698
1330
  };
699
- const requests = await this.pool.querySync(reqFilter);
1331
+ const rawRequests = await this.pool.querySync(reqFilter);
1332
+ const requests = rawRequests.filter(verifyEvent);
700
1333
  const requestIds = requests.map((r) => r.id);
701
1334
  let results = [];
702
1335
  let feedbacks = [];
703
1336
  if (requestIds.length > 0) {
704
- const [resultArrays, feedbackArrays] = await Promise.all([
705
- this.pool.queryBatchedByTag(
706
- { kinds: resultKinds },
707
- "e",
708
- requestIds
709
- ),
710
- this.pool.queryBatchedByTag(
711
- { kinds: [KIND_JOB_FEEDBACK] },
712
- "e",
713
- requestIds
714
- )
1337
+ const [rawResults, rawFeedbacks] = await Promise.all([
1338
+ this.pool.queryBatchedByTag({ kinds: resultKinds }, "e", requestIds),
1339
+ this.pool.queryBatchedByTag({ kinds: [KIND_JOB_FEEDBACK] }, "e", requestIds)
715
1340
  ]);
716
- results = resultArrays;
717
- feedbacks = feedbackArrays;
1341
+ results = rawResults.filter(verifyEvent);
1342
+ feedbacks = rawFeedbacks.filter(verifyEvent);
718
1343
  }
719
1344
  const targetedAgentByRequest = /* @__PURE__ */ new Map();
720
1345
  for (const req of requests) {
721
1346
  const pTag = req.tags.find((t) => t[0] === "p");
722
- if (pTag?.[1]) targetedAgentByRequest.set(req.id, pTag[1]);
1347
+ if (pTag?.[1]) {
1348
+ targetedAgentByRequest.set(req.id, pTag[1]);
1349
+ }
723
1350
  }
724
1351
  const resultsByRequest = /* @__PURE__ */ new Map();
725
1352
  for (const r of results) {
726
1353
  const reqId = resolveRequestId(r);
727
- if (!reqId) continue;
1354
+ if (!reqId) {
1355
+ continue;
1356
+ }
728
1357
  const targeted = targetedAgentByRequest.get(reqId);
729
- if (targeted && r.pubkey !== targeted) continue;
1358
+ if (targeted && r.pubkey !== targeted) {
1359
+ continue;
1360
+ }
730
1361
  const existing = resultsByRequest.get(reqId);
731
- if (!existing || targeted && r.pubkey === targeted) {
1362
+ if (!existing || r.created_at > existing.created_at) {
732
1363
  resultsByRequest.set(reqId, r);
733
1364
  }
734
1365
  }
735
1366
  const feedbackByRequest = /* @__PURE__ */ new Map();
736
1367
  for (const f of feedbacks) {
737
1368
  const reqId = resolveRequestId(f);
738
- if (!reqId) continue;
1369
+ if (!reqId) {
1370
+ continue;
1371
+ }
739
1372
  const targeted = targetedAgentByRequest.get(reqId);
740
- if (targeted && f.pubkey !== targeted) continue;
1373
+ if (targeted && f.pubkey !== targeted) {
1374
+ continue;
1375
+ }
741
1376
  const existing = feedbackByRequest.get(reqId);
742
- if (!existing || targeted && f.pubkey === targeted) {
1377
+ if (!existing || f.created_at > existing.created_at) {
743
1378
  feedbackByRequest.set(reqId, f);
744
1379
  }
745
1380
  }
1381
+ const feedbacksByRequestId = /* @__PURE__ */ new Map();
1382
+ for (const f of feedbacks) {
1383
+ const reqId = resolveRequestId(f);
1384
+ if (!reqId) {
1385
+ continue;
1386
+ }
1387
+ const arr = feedbacksByRequestId.get(reqId);
1388
+ if (arr) {
1389
+ arr.push(f);
1390
+ } else {
1391
+ feedbacksByRequestId.set(reqId, [f]);
1392
+ }
1393
+ }
746
1394
  const jobs = [];
747
1395
  for (const req of requests) {
748
1396
  const result = resultsByRequest.get(req.id);
749
1397
  const feedback = feedbackByRequest.get(req.id);
750
1398
  const jobAgentPubkey = result?.pubkey ?? feedback?.pubkey;
751
1399
  if (agentPubkeys && agentPubkeys.size > 0 && jobAgentPubkey) {
752
- if (!agentPubkeys.has(jobAgentPubkey)) continue;
1400
+ if (!agentPubkeys.has(jobAgentPubkey)) {
1401
+ continue;
1402
+ }
753
1403
  }
754
1404
  const capability = req.tags.find((t) => t[0] === "t" && t[1] !== "elisym")?.[1];
755
1405
  const bid = req.tags.find((t) => t[0] === "bid")?.[1];
@@ -759,11 +1409,9 @@ var MarketplaceService = class {
759
1409
  if (result) {
760
1410
  status = "success";
761
1411
  const amtTag = result.tags.find((t) => t[0] === "amount");
762
- if (amtTag?.[1]) amount = parseInt(amtTag[1], 10);
1412
+ amount = safeParseInt(amtTag?.[1]);
763
1413
  }
764
- const allFeedbacksForReq = feedbacks.filter(
765
- (f) => resolveRequestId(f) === req.id
766
- );
1414
+ const allFeedbacksForReq = feedbacksByRequestId.get(req.id) ?? [];
767
1415
  for (const fb of allFeedbacksForReq) {
768
1416
  const txTag = fb.tags.find((t) => t[0] === "tx");
769
1417
  if (txTag?.[1]) {
@@ -777,13 +1425,13 @@ var MarketplaceService = class {
777
1425
  if (statusTag?.[1]) {
778
1426
  const isTargeted = targetedAgentByRequest.has(req.id);
779
1427
  if (statusTag[1] === "payment-required" && !bid && !isTargeted) ; else {
780
- status = statusTag[1];
1428
+ status = toJobStatus(statusTag[1]);
781
1429
  }
782
1430
  }
783
1431
  }
784
1432
  if (!amount) {
785
1433
  const amtTag = feedback.tags.find((t) => t[0] === "amount");
786
- if (amtTag?.[1]) amount = parseInt(amtTag[1], 10);
1434
+ amount = safeParseInt(amtTag?.[1]);
787
1435
  }
788
1436
  }
789
1437
  jobs.push({
@@ -791,7 +1439,7 @@ var MarketplaceService = class {
791
1439
  customer: req.pubkey,
792
1440
  agentPubkey: jobAgentPubkey,
793
1441
  capability,
794
- bid: bid ? parseInt(bid, 10) : void 0,
1442
+ bid: safeParseInt(bid),
795
1443
  status,
796
1444
  result: result?.content,
797
1445
  resultEventId: result?.id,
@@ -810,96 +1458,285 @@ var MarketplaceService = class {
810
1458
  "#t": ["elisym"],
811
1459
  since: Math.floor(Date.now() / 1e3)
812
1460
  },
813
- onEvent
1461
+ (event) => {
1462
+ if (!verifyEvent(event)) {
1463
+ return;
1464
+ }
1465
+ onEvent(event);
1466
+ }
814
1467
  );
815
1468
  }
816
1469
  };
817
- var MessagingService = class _MessagingService {
818
- // 30s
819
- constructor(pool) {
820
- this.pool = pool;
821
- this.sessionIdentity = ElisymIdentity.generate();
1470
+ var KIND_HTTP_AUTH = 27235;
1471
+ var DEFAULT_UPLOAD_URL = "https://nostr.build/api/v2/upload/files";
1472
+ var MediaService = class {
1473
+ constructor(uploadUrl = DEFAULT_UPLOAD_URL) {
1474
+ this.uploadUrl = uploadUrl;
822
1475
  }
823
- sessionIdentity;
824
- pingCache = /* @__PURE__ */ new Map();
825
- // pubkey → timestamp of last online result
826
- pendingPings = /* @__PURE__ */ new Map();
827
- // dedup in-flight pings
828
- static PING_CACHE_TTL = 3e4;
829
1476
  /**
830
- * Ping an agent via ephemeral Nostr events (kind 20200/20201).
831
- * Uses a persistent session identity to avoid relay rate-limiting.
832
- * Publishes to ALL relays for maximum delivery reliability.
833
- * Caches results for 30s to prevent redundant publishes.
1477
+ * Upload a file with NIP-98 authentication.
1478
+ * Works with browser File objects and Node.js/Bun Blobs.
1479
+ *
1480
+ * @param identity - Nostr identity used to sign the NIP-98 auth event.
1481
+ * @param file - File or Blob to upload.
1482
+ * @param filename - Optional filename for the upload (defaults to "upload").
1483
+ * @returns URL of the uploaded file.
834
1484
  */
835
- async pingAgent(agentPubkey, timeoutMs = 15e3, signal) {
836
- const cachedAt = this.pingCache.get(agentPubkey);
837
- if (cachedAt && Date.now() - cachedAt < _MessagingService.PING_CACHE_TTL) {
838
- console.log(`[ping] cache hit for ${agentPubkey.slice(0, 8)}: online`);
839
- return { online: true, identity: this.sessionIdentity };
1485
+ async upload(identity, file, filename) {
1486
+ const hashBuffer = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
1487
+ const hashHex = [...new Uint8Array(hashBuffer)].map((b) => b.toString(16).padStart(2, "0")).join("");
1488
+ const authEvent = finalizeEvent(
1489
+ {
1490
+ kind: KIND_HTTP_AUTH,
1491
+ created_at: Math.floor(Date.now() / 1e3),
1492
+ tags: [
1493
+ ["u", this.uploadUrl],
1494
+ ["method", "POST"],
1495
+ ["payload", hashHex]
1496
+ ],
1497
+ content: ""
1498
+ },
1499
+ identity.secretKey
1500
+ );
1501
+ const authHeader = "Nostr " + btoa(JSON.stringify(authEvent));
1502
+ const formData = new FormData();
1503
+ formData.append("file", file, filename ?? "upload");
1504
+ const controller = new AbortController();
1505
+ const timer = setTimeout(() => controller.abort(), 3e4);
1506
+ try {
1507
+ const res = await fetch(this.uploadUrl, {
1508
+ method: "POST",
1509
+ headers: { Authorization: authHeader },
1510
+ body: formData,
1511
+ signal: controller.signal
1512
+ });
1513
+ if (!res.ok) {
1514
+ throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
1515
+ }
1516
+ let data;
1517
+ try {
1518
+ data = await res.json();
1519
+ } catch {
1520
+ throw new Error("Invalid response from upload service.");
1521
+ }
1522
+ const url = data?.data?.[0]?.url;
1523
+ if (!url) {
1524
+ throw new Error("No URL returned from upload service.");
1525
+ }
1526
+ return url;
1527
+ } finally {
1528
+ clearTimeout(timer);
840
1529
  }
841
- const pending = this.pendingPings.get(agentPubkey);
842
- if (pending) {
843
- console.log(`[ping] dedup: reusing in-flight ping for ${agentPubkey.slice(0, 8)}`);
844
- return pending;
1530
+ }
1531
+ };
1532
+
1533
+ // src/primitives/bounded-set.ts
1534
+ var BoundedSet = class {
1535
+ constructor(maxSize) {
1536
+ this.maxSize = maxSize;
1537
+ if (maxSize <= 0) {
1538
+ throw new Error("BoundedSet maxSize must be positive.");
845
1539
  }
846
- const promise = this._doPing(agentPubkey, timeoutMs, signal);
847
- this.pendingPings.set(agentPubkey, promise);
848
- promise.finally(() => this.pendingPings.delete(agentPubkey));
849
- return promise;
1540
+ this.items = new Array(maxSize);
850
1541
  }
851
- async _doPing(agentPubkey, timeoutMs, signal) {
1542
+ items;
1543
+ set = /* @__PURE__ */ new Set();
1544
+ head = 0;
1545
+ count = 0;
1546
+ has(item) {
1547
+ return this.set.has(item);
1548
+ }
1549
+ add(item) {
1550
+ if (this.set.has(item)) {
1551
+ return;
1552
+ }
1553
+ if (this.count >= this.maxSize) {
1554
+ const evicted = this.items[this.head];
1555
+ this.set.delete(evicted);
1556
+ } else {
1557
+ this.count++;
1558
+ }
1559
+ this.items[this.head] = item;
1560
+ this.head = (this.head + 1) % this.maxSize;
1561
+ this.set.add(item);
1562
+ }
1563
+ };
1564
+ var ElisymIdentity = class _ElisymIdentity {
1565
+ _secretKey;
1566
+ publicKey;
1567
+ npub;
1568
+ get secretKey() {
1569
+ return new Uint8Array(this._secretKey);
1570
+ }
1571
+ constructor(secretKey) {
1572
+ this._secretKey = new Uint8Array(secretKey);
1573
+ this.publicKey = getPublicKey(secretKey);
1574
+ this.npub = nip19.npubEncode(this.publicKey);
1575
+ }
1576
+ static generate() {
1577
+ return new _ElisymIdentity(generateSecretKey());
1578
+ }
1579
+ static fromSecretKey(sk) {
1580
+ if (sk.length !== 32) {
1581
+ throw new Error("Secret key must be exactly 32 bytes.");
1582
+ }
1583
+ return new _ElisymIdentity(sk);
1584
+ }
1585
+ toJSON() {
1586
+ return { publicKey: this.publicKey, npub: this.npub };
1587
+ }
1588
+ /** Best-effort scrub of the secret key bytes in memory. */
1589
+ scrub() {
1590
+ this._secretKey.fill(0);
1591
+ }
1592
+ static fromHex(hex) {
1593
+ if (hex.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(hex)) {
1594
+ throw new Error("Invalid secret key hex: expected 64 hex characters (32 bytes).");
1595
+ }
1596
+ const bytes = new Uint8Array(32);
1597
+ for (let i = 0; i < 64; i += 2) {
1598
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
1599
+ }
1600
+ return new _ElisymIdentity(bytes);
1601
+ }
1602
+ };
1603
+
1604
+ // src/services/messaging.ts
1605
+ var MessagingService = class _MessagingService {
1606
+ // dedup in-flight pings
1607
+ constructor(pool) {
1608
+ this.pool = pool;
1609
+ this.sessionIdentity = ElisymIdentity.generate();
1610
+ }
1611
+ static PING_CACHE_MAX = 1e3;
1612
+ sessionIdentity;
1613
+ pingCache = /* @__PURE__ */ new Map();
1614
+ // pubkey - timestamp of last online result
1615
+ pendingPings = /* @__PURE__ */ new Map();
1616
+ /**
1617
+ * Ping an agent via ephemeral Nostr events (kind 20200/20201).
1618
+ * Uses a persistent session identity to avoid relay rate-limiting.
1619
+ * Publishes to ALL relays for maximum delivery reliability.
1620
+ * Caches results for 30s to prevent redundant publishes.
1621
+ */
1622
+ async pingAgent(agentPubkey, timeoutMs = DEFAULTS.PING_TIMEOUT_MS, signal, retries = DEFAULTS.PING_RETRIES) {
1623
+ const cachedAt = this.pingCache.get(agentPubkey);
1624
+ if (cachedAt) {
1625
+ if (Date.now() - cachedAt < DEFAULTS.PING_CACHE_TTL_MS) {
1626
+ return { online: true, identity: this.sessionIdentity };
1627
+ }
1628
+ this.pingCache.delete(agentPubkey);
1629
+ }
1630
+ if (this.pingCache.size > _MessagingService.PING_CACHE_MAX / 2) {
1631
+ const now = Date.now();
1632
+ for (const [key, ts] of this.pingCache) {
1633
+ if (now - ts >= DEFAULTS.PING_CACHE_TTL_MS) {
1634
+ this.pingCache.delete(key);
1635
+ }
1636
+ }
1637
+ }
1638
+ const pending = this.pendingPings.get(agentPubkey);
1639
+ if (pending) {
1640
+ return pending;
1641
+ }
1642
+ if (this.pendingPings.size >= _MessagingService.PING_CACHE_MAX) {
1643
+ return { online: false, identity: null };
1644
+ }
1645
+ const promise = this._doPingWithRetry(agentPubkey, timeoutMs, retries, signal);
1646
+ this.pendingPings.set(agentPubkey, promise);
1647
+ promise.finally(() => this.pendingPings.delete(agentPubkey));
1648
+ return promise;
1649
+ }
1650
+ async _doPingWithRetry(agentPubkey, timeoutMs, retries, signal) {
1651
+ const attempts = retries + 1;
1652
+ const perAttemptTimeout = Math.floor(timeoutMs / attempts);
1653
+ for (let i = 0; i < attempts; i++) {
1654
+ if (signal?.aborted) {
1655
+ return { online: false, identity: null };
1656
+ }
1657
+ const result = await this._doPing(agentPubkey, perAttemptTimeout, signal);
1658
+ if (result.online) {
1659
+ return result;
1660
+ }
1661
+ }
1662
+ return { online: false, identity: null };
1663
+ }
1664
+ async _doPing(agentPubkey, timeoutMs, signal) {
852
1665
  const sk = this.sessionIdentity.secretKey;
853
1666
  const pk = this.sessionIdentity.publicKey;
854
1667
  const nonce = crypto.getRandomValues(new Uint8Array(16)).reduce((s, b) => s + b.toString(16).padStart(2, "0"), "");
855
- const shortNonce = nonce.slice(0, 8);
856
- const shortAgent = agentPubkey.slice(0, 8);
857
- console.log(`[ping] \u2192 ping ${shortAgent} nonce=${shortNonce}`);
858
1668
  if (signal?.aborted) {
859
1669
  return { online: false, identity: null };
860
1670
  }
861
- return new Promise(async (resolve) => {
862
- let resolved = false;
863
- const done = (online, reason) => {
864
- if (resolved) return;
865
- resolved = true;
1671
+ let resolved = false;
1672
+ let sub;
1673
+ let timer;
1674
+ let resolvePing;
1675
+ const promise = new Promise((resolve) => {
1676
+ resolvePing = resolve;
1677
+ });
1678
+ const done = (online) => {
1679
+ if (resolved) {
1680
+ return;
1681
+ }
1682
+ resolved = true;
1683
+ if (timer) {
866
1684
  clearTimeout(timer);
867
- sub.close();
868
- signal?.removeEventListener("abort", onAbort);
869
- console.log(`[ping] ${online ? "\u2713 ONLINE" : "\u2717 OFFLINE"} agent=${shortAgent} nonce=${shortNonce}${reason ? ` (${reason})` : ""}`);
870
- if (online) this.pingCache.set(agentPubkey, Date.now());
871
- resolve({ online, identity: online ? this.sessionIdentity : null });
872
- };
873
- const onAbort = () => done(false, "aborted");
874
- signal?.addEventListener("abort", onAbort);
875
- const sub = this.pool.subscribe(
876
- { kinds: [KIND_PONG], "#p": [pk] },
877
- (ev) => {
878
- try {
879
- const msg = JSON.parse(ev.content);
880
- if (msg.type === "elisym_pong" && msg.nonce === nonce) {
881
- console.log(`[ping] \u2190 pong from ${ev.pubkey.slice(0, 8)} nonce=${shortNonce}`);
882
- done(true, "pong matched");
883
- }
884
- } catch {
1685
+ }
1686
+ sub?.close();
1687
+ signal?.removeEventListener("abort", onAbort);
1688
+ if (online) {
1689
+ this.pingCache.delete(agentPubkey);
1690
+ this.pingCache.set(agentPubkey, Date.now());
1691
+ if (this.pingCache.size > _MessagingService.PING_CACHE_MAX) {
1692
+ const oldest = this.pingCache.keys().next().value;
1693
+ if (oldest !== void 0) {
1694
+ this.pingCache.delete(oldest);
885
1695
  }
886
1696
  }
887
- );
888
- const pingEvent = finalizeEvent(
889
- {
890
- kind: KIND_PING,
891
- created_at: Math.floor(Date.now() / 1e3),
892
- tags: [["p", agentPubkey]],
893
- content: JSON.stringify({ type: "elisym_ping", nonce })
894
- },
895
- sk
896
- );
897
- this.pool.publishAll(pingEvent).then(() => console.log(`[ping] \u2713 published nonce=${shortNonce}`)).catch((err) => {
898
- console.error(`[ping] \u2717 publish failed nonce=${shortNonce}`, err);
899
- done(false, "publish failed");
1697
+ }
1698
+ resolvePing({ online, identity: online ? this.sessionIdentity : null });
1699
+ };
1700
+ const onAbort = () => done(false);
1701
+ signal?.addEventListener("abort", onAbort);
1702
+ timer = setTimeout(() => done(false), timeoutMs);
1703
+ try {
1704
+ sub = await this.pool.subscribeAndWait({ kinds: [KIND_PONG], "#p": [pk] }, (ev) => {
1705
+ if (!verifyEvent(ev)) {
1706
+ return;
1707
+ }
1708
+ if (ev.pubkey !== agentPubkey) {
1709
+ return;
1710
+ }
1711
+ try {
1712
+ const msg = JSON.parse(ev.content);
1713
+ if (msg.type === "elisym_pong" && typeof msg.nonce === "string" && msg.nonce.length === 32 && msg.nonce === nonce) {
1714
+ done(true);
1715
+ }
1716
+ } catch {
1717
+ }
900
1718
  });
901
- const timer = setTimeout(() => done(false, "timeout"), timeoutMs);
1719
+ } catch {
1720
+ done(false);
1721
+ return promise;
1722
+ }
1723
+ if (resolved) {
1724
+ sub?.close();
1725
+ return promise;
1726
+ }
1727
+ const pingEvent = finalizeEvent(
1728
+ {
1729
+ kind: KIND_PING,
1730
+ created_at: Math.floor(Date.now() / 1e3),
1731
+ tags: [["p", agentPubkey]],
1732
+ content: JSON.stringify({ type: "elisym_ping", nonce })
1733
+ },
1734
+ sk
1735
+ );
1736
+ this.pool.publishAll(pingEvent).catch(() => {
1737
+ done(false);
902
1738
  });
1739
+ return promise;
903
1740
  }
904
1741
  /**
905
1742
  * Subscribe to incoming ephemeral ping events (kind 20200).
@@ -909,9 +1746,12 @@ var MessagingService = class _MessagingService {
909
1746
  return this.pool.subscribe(
910
1747
  { kinds: [KIND_PING], "#p": [identity.publicKey] },
911
1748
  (ev) => {
1749
+ if (!verifyEvent(ev)) {
1750
+ return;
1751
+ }
912
1752
  try {
913
1753
  const msg = JSON.parse(ev.content);
914
- if (msg.type === "elisym_ping" && msg.nonce) {
1754
+ if (msg.type === "elisym_ping" && typeof msg.nonce === "string" && msg.nonce.length === 32) {
915
1755
  onPing(ev.pubkey, msg.nonce);
916
1756
  }
917
1757
  } catch {
@@ -934,11 +1774,15 @@ var MessagingService = class _MessagingService {
934
1774
  }
935
1775
  /** Send a NIP-17 DM. */
936
1776
  async sendMessage(identity, recipientPubkey, content) {
937
- const wrap = nip17.wrapEvent(
938
- identity.secretKey,
939
- { publicKey: recipientPubkey },
940
- content
941
- );
1777
+ if (!/^[0-9a-f]{64}$/.test(recipientPubkey)) {
1778
+ throw new Error("Invalid recipient pubkey: expected 64 hex characters.");
1779
+ }
1780
+ if (content.length > LIMITS.MAX_MESSAGE_LENGTH) {
1781
+ throw new Error(
1782
+ `Message too long: ${content.length} chars (max ${LIMITS.MAX_MESSAGE_LENGTH}).`
1783
+ );
1784
+ }
1785
+ const wrap = nip17.wrapEvent(identity.secretKey, { publicKey: recipientPubkey }, content);
942
1786
  await this.pool.publish(wrap);
943
1787
  }
944
1788
  /** Fetch historical NIP-17 DMs from relays. Returns decrypted messages sorted by time. */
@@ -948,12 +1792,17 @@ var MessagingService = class _MessagingService {
948
1792
  "#p": [identity.publicKey],
949
1793
  since
950
1794
  });
951
- const seen = /* @__PURE__ */ new Set();
1795
+ const seen = new BoundedSet(1e4);
952
1796
  const messages = [];
953
1797
  for (const ev of events) {
954
1798
  try {
955
1799
  const rumor = nip59.unwrapEvent(ev, identity.secretKey);
956
- if (seen.has(rumor.id)) continue;
1800
+ if (rumor.kind !== 14) {
1801
+ continue;
1802
+ }
1803
+ if (seen.has(rumor.id)) {
1804
+ continue;
1805
+ }
957
1806
  seen.add(rumor.id);
958
1807
  messages.push({
959
1808
  senderPubkey: rumor.pubkey,
@@ -968,134 +1817,312 @@ var MessagingService = class _MessagingService {
968
1817
  }
969
1818
  /** Subscribe to incoming NIP-17 DMs. */
970
1819
  subscribeToMessages(identity, onMessage, since) {
971
- const seen = /* @__PURE__ */ new Set();
1820
+ const seen = new BoundedSet(1e4);
972
1821
  const filter = {
973
1822
  kinds: [KIND_GIFT_WRAP],
974
1823
  "#p": [identity.publicKey]
975
1824
  };
976
- if (since !== void 0) filter.since = since;
977
- return this.pool.subscribe(
978
- filter,
979
- (ev) => {
980
- try {
981
- const rumor = nip59.unwrapEvent(ev, identity.secretKey);
982
- if (seen.has(rumor.id)) return;
983
- seen.add(rumor.id);
984
- onMessage(rumor.pubkey, rumor.content, rumor.created_at, rumor.id);
985
- } catch {
1825
+ if (since !== void 0) {
1826
+ filter.since = since;
1827
+ }
1828
+ return this.pool.subscribe(filter, (ev) => {
1829
+ try {
1830
+ const rumor = nip59.unwrapEvent(ev, identity.secretKey);
1831
+ if (rumor.kind !== 14) {
1832
+ return;
1833
+ }
1834
+ if (seen.has(rumor.id)) {
1835
+ return;
986
1836
  }
1837
+ seen.add(rumor.id);
1838
+ onMessage(rumor.pubkey, rumor.content, rumor.created_at, rumor.id);
1839
+ } catch {
987
1840
  }
988
- );
1841
+ });
989
1842
  }
990
1843
  };
991
- var PaymentService = class _PaymentService {
992
- /**
993
- * Calculate protocol fee using Decimal basis-point math (no floats).
994
- * Returns ceil(amount * PROTOCOL_FEE_BPS / 10000).
995
- */
996
- static calculateProtocolFee(amount) {
997
- if (amount === 0) return 0;
998
- return new Decimal(amount).mul(PROTOCOL_FEE_BPS).div(1e4).toDecimalPlaces(0, Decimal.ROUND_CEIL).toNumber();
1844
+ var NostrPool = class {
1845
+ pool;
1846
+ relays;
1847
+ activeSubscriptions = /* @__PURE__ */ new Set();
1848
+ constructor(relays = RELAYS) {
1849
+ this.pool = new SimplePool();
1850
+ this.relays = relays;
999
1851
  }
1000
- /**
1001
- * Validate that a payment request has the correct recipient and protocol fee.
1002
- * Returns an error message if invalid, null if OK.
1003
- */
1004
- static validatePaymentFee(requestJson, expectedRecipient) {
1005
- let data;
1852
+ /** Query relays synchronously. Returns `[]` on timeout (no error thrown). */
1853
+ async querySync(filter) {
1854
+ let timer;
1855
+ const query = this.pool.querySync(this.relays, filter);
1856
+ query.catch(() => {
1857
+ });
1006
1858
  try {
1007
- data = JSON.parse(requestJson);
1008
- } catch (e) {
1009
- return `Invalid payment request JSON: ${e}`;
1859
+ const result = await Promise.race([
1860
+ query,
1861
+ new Promise((resolve) => {
1862
+ timer = setTimeout(() => resolve([]), DEFAULTS.QUERY_TIMEOUT_MS);
1863
+ })
1864
+ ]);
1865
+ return result;
1866
+ } finally {
1867
+ clearTimeout(timer);
1010
1868
  }
1011
- if (expectedRecipient && data.recipient !== expectedRecipient) {
1012
- return `Recipient mismatch: expected ${expectedRecipient}, got ${data.recipient}. Provider may be attempting to redirect payment.`;
1869
+ }
1870
+ async queryBatched(filter, keys, batchSize = DEFAULTS.BATCH_SIZE, maxConcurrency = DEFAULTS.QUERY_MAX_CONCURRENCY) {
1871
+ const batchKeys = [];
1872
+ for (let i = 0; i < keys.length; i += batchSize) {
1873
+ batchKeys.push(keys.slice(i, i + batchSize));
1874
+ }
1875
+ const results = [];
1876
+ for (let c = 0; c < batchKeys.length; c += maxConcurrency) {
1877
+ const chunk = batchKeys.slice(c, c + maxConcurrency);
1878
+ const chunkResults = await Promise.all(
1879
+ chunk.map((batch) => {
1880
+ let timer;
1881
+ const query = this.pool.querySync(this.relays, {
1882
+ ...filter,
1883
+ authors: batch
1884
+ });
1885
+ query.catch(() => {
1886
+ });
1887
+ return (async () => {
1888
+ try {
1889
+ return await Promise.race([
1890
+ query,
1891
+ new Promise((resolve) => {
1892
+ timer = setTimeout(() => resolve([]), DEFAULTS.QUERY_TIMEOUT_MS);
1893
+ })
1894
+ ]);
1895
+ } finally {
1896
+ clearTimeout(timer);
1897
+ }
1898
+ })();
1899
+ })
1900
+ );
1901
+ results.push(...chunkResults.flat());
1013
1902
  }
1014
- if (data.created_at > 0 && data.expiry_secs > 0) {
1015
- const elapsed = Math.floor(Date.now() / 1e3) - data.created_at;
1016
- if (elapsed > data.expiry_secs) {
1017
- return `Payment request expired (created ${data.created_at}, expiry ${data.expiry_secs}s).`;
1903
+ return results;
1904
+ }
1905
+ async queryBatchedByTag(filter, tagName, values, batchSize = DEFAULTS.BATCH_SIZE, maxConcurrency = DEFAULTS.QUERY_MAX_CONCURRENCY) {
1906
+ const batchValues = [];
1907
+ for (let i = 0; i < values.length; i += batchSize) {
1908
+ batchValues.push(values.slice(i, i + batchSize));
1909
+ }
1910
+ const results = [];
1911
+ for (let c = 0; c < batchValues.length; c += maxConcurrency) {
1912
+ const chunk = batchValues.slice(c, c + maxConcurrency);
1913
+ const chunkResults = await Promise.all(
1914
+ chunk.map((batch) => {
1915
+ let timer;
1916
+ const query = this.pool.querySync(this.relays, {
1917
+ ...filter,
1918
+ [`#${tagName}`]: batch
1919
+ });
1920
+ query.catch(() => {
1921
+ });
1922
+ return (async () => {
1923
+ try {
1924
+ return await Promise.race([
1925
+ query,
1926
+ new Promise((resolve) => {
1927
+ timer = setTimeout(() => resolve([]), DEFAULTS.QUERY_TIMEOUT_MS);
1928
+ })
1929
+ ]);
1930
+ } finally {
1931
+ clearTimeout(timer);
1932
+ }
1933
+ })();
1934
+ })
1935
+ );
1936
+ results.push(...chunkResults.flat());
1937
+ }
1938
+ return results;
1939
+ }
1940
+ async publish(event) {
1941
+ try {
1942
+ await Promise.any(this.pool.publish(this.relays, event));
1943
+ } catch (err) {
1944
+ if (err instanceof AggregateError) {
1945
+ throw new Error(
1946
+ `Failed to publish to all ${this.relays.length} relays: ${err.errors.map((e) => e instanceof Error ? e.message : String(e)).join(", ")}`
1947
+ );
1018
1948
  }
1949
+ throw err;
1019
1950
  }
1020
- const expectedFee = _PaymentService.calculateProtocolFee(data.amount);
1021
- const { fee_address, fee_amount } = data;
1022
- if (fee_address && fee_amount && fee_amount > 0) {
1023
- if (fee_address !== PROTOCOL_TREASURY) {
1024
- return `Fee address mismatch: expected ${PROTOCOL_TREASURY}, got ${fee_address}. Provider may be attempting to redirect fees.`;
1951
+ }
1952
+ /** Publish to all relays and wait for all to settle. Throws if none accepted. */
1953
+ async publishAll(event) {
1954
+ const results = await Promise.allSettled(this.pool.publish(this.relays, event));
1955
+ const anyOk = results.some((r) => r.status === "fulfilled");
1956
+ if (!anyOk) {
1957
+ throw new Error(`Failed to publish to all ${this.relays.length} relays`);
1958
+ }
1959
+ }
1960
+ subscribe(filter, onEvent) {
1961
+ const rawSub = this.pool.subscribeMany(this.relays, filter, { onevent: onEvent });
1962
+ const tracked = {
1963
+ close: (reason) => {
1964
+ this.activeSubscriptions.delete(tracked);
1965
+ rawSub.close(reason);
1025
1966
  }
1026
- if (fee_amount !== expectedFee) {
1027
- return `Fee amount mismatch: expected ${expectedFee} lamports (${PROTOCOL_FEE_BPS}bps of ${data.amount}), got ${fee_amount}. Provider may be tampering with fee.`;
1967
+ };
1968
+ this.activeSubscriptions.add(tracked);
1969
+ return tracked;
1970
+ }
1971
+ /**
1972
+ * Subscribe and wait until at least one relay confirms the subscription
1973
+ * is active (EOSE). Resolves on the first relay that responds.
1974
+ * Essential for ephemeral events where the subscription must be live
1975
+ * before publishing.
1976
+ *
1977
+ * Note: resolves on timeout even if no relay sent EOSE. The caller
1978
+ * cannot distinguish timeout from success - this is intentional for
1979
+ * best-effort ephemeral event delivery.
1980
+ */
1981
+ subscribeAndWait(filter, onEvent, timeoutMs = DEFAULTS.EOSE_TIMEOUT_MS) {
1982
+ return new Promise((resolve) => {
1983
+ let resolved = false;
1984
+ let timer;
1985
+ const subs = [];
1986
+ const combinedSub = {
1987
+ close: (reason) => {
1988
+ this.activeSubscriptions.delete(combinedSub);
1989
+ for (const s of subs) {
1990
+ s.close(reason);
1991
+ }
1992
+ }
1993
+ };
1994
+ this.activeSubscriptions.add(combinedSub);
1995
+ const done = () => {
1996
+ if (resolved) {
1997
+ return;
1998
+ }
1999
+ resolved = true;
2000
+ if (timer) {
2001
+ clearTimeout(timer);
2002
+ }
2003
+ resolve(combinedSub);
2004
+ };
2005
+ const seen = new BoundedSet(1e4);
2006
+ const dedupedOnEvent = (ev) => {
2007
+ if (seen.has(ev.id)) {
2008
+ return;
2009
+ }
2010
+ seen.add(ev.id);
2011
+ onEvent(ev);
2012
+ };
2013
+ for (const relay of this.relays) {
2014
+ try {
2015
+ const sub = this.pool.subscribeMany([relay], filter, {
2016
+ onevent: dedupedOnEvent,
2017
+ oneose: done
2018
+ });
2019
+ subs.push(sub);
2020
+ } catch {
2021
+ }
1028
2022
  }
1029
- return null;
2023
+ if (subs.length === 0) {
2024
+ done();
2025
+ return;
2026
+ }
2027
+ if (!resolved) {
2028
+ timer = setTimeout(done, timeoutMs);
2029
+ }
2030
+ });
2031
+ }
2032
+ /**
2033
+ * Tear down pool and create a fresh one.
2034
+ * Works around nostr-tools `onerror - skipReconnection = true` bug
2035
+ * that permanently kills subscriptions. Callers must re-subscribe.
2036
+ */
2037
+ reset() {
2038
+ for (const sub of this.activeSubscriptions) {
2039
+ sub.close("pool reset");
1030
2040
  }
1031
- if (!fee_address && !fee_amount) {
1032
- return `Payment request missing protocol fee (${PROTOCOL_FEE_BPS}bps). Expected fee: ${expectedFee} lamports to ${PROTOCOL_TREASURY}.`;
2041
+ this.activeSubscriptions.clear();
2042
+ try {
2043
+ this.pool.close(this.relays);
2044
+ } catch {
1033
2045
  }
1034
- return `Invalid fee params in payment request. Expected fee: ${expectedFee} lamports to ${PROTOCOL_TREASURY}.`;
2046
+ this.pool = new SimplePool();
1035
2047
  }
1036
2048
  /**
1037
- * Build a Solana transaction from a payment request.
1038
- * The caller must sign and send via wallet adapter.
2049
+ * Lightweight connectivity probe. Returns true if at least one relay responds.
1039
2050
  */
1040
- static buildPaymentTransaction(payerPubkey, paymentRequest) {
1041
- const recipient = new PublicKey(paymentRequest.recipient);
1042
- const reference = new PublicKey(paymentRequest.reference);
1043
- const feeAddress = paymentRequest.fee_address ? new PublicKey(paymentRequest.fee_address) : null;
1044
- const feeAmount = paymentRequest.fee_amount ?? 0;
1045
- const providerAmount = feeAddress && feeAmount > 0 ? new Decimal(paymentRequest.amount).minus(feeAmount).toNumber() : paymentRequest.amount;
1046
- const transferIx = SystemProgram.transfer({
1047
- fromPubkey: payerPubkey,
1048
- toPubkey: recipient,
1049
- lamports: providerAmount
1050
- });
1051
- transferIx.keys.push({
1052
- pubkey: reference,
1053
- isSigner: false,
1054
- isWritable: false
2051
+ async probe(timeoutMs = DEFAULTS.EOSE_TIMEOUT_MS) {
2052
+ let timer;
2053
+ const query = this.pool.querySync(this.relays, { kinds: [0], limit: 1 });
2054
+ query.catch(() => {
1055
2055
  });
1056
- const tx = new Transaction().add(transferIx);
1057
- if (feeAddress && feeAmount > 0) {
1058
- tx.add(
1059
- SystemProgram.transfer({
1060
- fromPubkey: payerPubkey,
1061
- toPubkey: feeAddress,
1062
- lamports: feeAmount
2056
+ try {
2057
+ await Promise.race([
2058
+ query,
2059
+ new Promise((_, reject) => {
2060
+ timer = setTimeout(() => reject(new Error("probe timeout")), timeoutMs);
1063
2061
  })
1064
- );
2062
+ ]);
2063
+ return true;
2064
+ } catch {
2065
+ return false;
2066
+ } finally {
2067
+ clearTimeout(timer);
1065
2068
  }
1066
- return tx;
1067
2069
  }
1068
- /**
1069
- * Create a payment request with auto-calculated protocol fee.
1070
- * Used by providers to generate payment requests for customers.
1071
- */
1072
- static createPaymentRequest(recipientAddress, amount, expirySecs = 600) {
1073
- const feeAmount = _PaymentService.calculateProtocolFee(amount);
1074
- const reference = PublicKey.unique().toBase58();
1075
- return {
1076
- recipient: recipientAddress,
1077
- amount,
1078
- reference,
1079
- fee_address: PROTOCOL_TREASURY,
1080
- fee_amount: feeAmount,
1081
- created_at: Math.floor(Date.now() / 1e3),
1082
- expiry_secs: expirySecs
1083
- };
2070
+ getRelays() {
2071
+ return this.relays;
2072
+ }
2073
+ close() {
2074
+ for (const sub of this.activeSubscriptions) {
2075
+ sub.close("pool closed");
2076
+ }
2077
+ this.activeSubscriptions.clear();
2078
+ try {
2079
+ this.pool.close(this.relays);
2080
+ } catch {
2081
+ }
2082
+ }
2083
+ };
2084
+
2085
+ // src/client.ts
2086
+ var ElisymClient = class {
2087
+ pool;
2088
+ discovery;
2089
+ marketplace;
2090
+ messaging;
2091
+ media;
2092
+ payment;
2093
+ constructor(config = {}) {
2094
+ this.pool = new NostrPool(config.relays ?? RELAYS);
2095
+ this.discovery = new DiscoveryService(this.pool);
2096
+ this.marketplace = new MarketplaceService(this.pool);
2097
+ this.messaging = new MessagingService(this.pool);
2098
+ this.media = new MediaService(config.uploadUrl);
2099
+ this.payment = config.payment ?? new SolanaPaymentStrategy();
2100
+ }
2101
+ close() {
2102
+ this.pool.close();
1084
2103
  }
1085
2104
  };
1086
2105
  function formatSol(lamports) {
1087
- const sol = new Decimal(lamports).div(LAMPORTS_PER_SOL);
1088
- if (sol.gte(1e6)) return `${sol.idiv(1e6)}m SOL`;
1089
- if (sol.gte(1e4)) return `${sol.idiv(1e3)}k SOL`;
2106
+ const sol = new Decimal2(lamports).div(LAMPORTS_PER_SOL);
2107
+ if (sol.gte(1e6)) {
2108
+ return `${sol.idiv(1e6)}m SOL`;
2109
+ }
2110
+ if (sol.gte(1e4)) {
2111
+ return `${sol.idiv(1e3)}k SOL`;
2112
+ }
1090
2113
  return `${compactSol(sol)} SOL`;
1091
2114
  }
1092
2115
  function compactSol(sol) {
1093
- if (sol.isZero()) return "0";
1094
- if (sol.gte(1e3)) return sol.toDecimalPlaces(0, Decimal.ROUND_FLOOR).toString();
2116
+ if (sol.isZero()) {
2117
+ return "0";
2118
+ }
2119
+ if (sol.gte(1e3)) {
2120
+ return sol.toDecimalPlaces(0, Decimal2.ROUND_FLOOR).toString();
2121
+ }
1095
2122
  const maxFrac = 9;
1096
2123
  for (let d = 1; d <= maxFrac; d++) {
1097
2124
  const s = sol.toFixed(d);
1098
- if (new Decimal(s).eq(sol)) {
2125
+ if (new Decimal2(s).eq(sol)) {
1099
2126
  return s.replace(/0+$/, "").replace(/\.$/, "");
1100
2127
  }
1101
2128
  }
@@ -1103,43 +2130,37 @@ function compactSol(sol) {
1103
2130
  }
1104
2131
  function timeAgo(unix) {
1105
2132
  const seconds = Math.max(0, Math.floor(Date.now() / 1e3 - unix));
1106
- if (seconds < 60) return `${seconds}s ago`;
2133
+ if (seconds < 60) {
2134
+ return `${seconds}s ago`;
2135
+ }
1107
2136
  const minutes = Math.floor(seconds / 60);
1108
- if (minutes < 60) return `${minutes}m ago`;
2137
+ if (minutes < 60) {
2138
+ return `${minutes}m ago`;
2139
+ }
1109
2140
  const hours = Math.floor(minutes / 60);
1110
- if (hours < 24) return `${hours}h ago`;
2141
+ if (hours < 24) {
2142
+ return `${hours}h ago`;
2143
+ }
1111
2144
  const days = Math.floor(hours / 24);
1112
2145
  return `${days}d ago`;
1113
2146
  }
1114
2147
  function truncateKey(hex, chars = 6) {
1115
- if (hex.length <= chars * 2) return hex;
2148
+ if (hex.length <= chars * 2) {
2149
+ return hex;
2150
+ }
1116
2151
  return `${hex.slice(0, chars)}...${hex.slice(-chars)}`;
1117
2152
  }
1118
- function makeNjumpUrl(eventId, relays = RELAYS) {
1119
- const nevent = nip19.neventEncode({
1120
- id: eventId,
1121
- relays: relays.slice(0, 2)
1122
- });
1123
- return `https://njump.me/${nevent}`;
1124
- }
1125
2153
 
1126
- // src/index.ts
1127
- var ElisymClient = class {
1128
- pool;
1129
- discovery;
1130
- marketplace;
1131
- messaging;
1132
- constructor(config = {}) {
1133
- this.pool = new NostrPool(config.relays ?? RELAYS);
1134
- this.discovery = new DiscoveryService(this.pool);
1135
- this.marketplace = new MarketplaceService(this.pool);
1136
- this.messaging = new MessagingService(this.pool);
1137
- }
1138
- close() {
1139
- this.pool.close();
2154
+ // src/primitives/config.ts
2155
+ function validateAgentName(name) {
2156
+ if (!name || name.length > LIMITS.MAX_AGENT_NAME_LENGTH || !/^[a-zA-Z0-9_-]+$/.test(name)) {
2157
+ throw new Error("Agent name must be 1-64 characters, alphanumeric, underscore, or hyphen.");
1140
2158
  }
1141
- };
2159
+ }
2160
+ function serializeConfig(config) {
2161
+ return JSON.stringify(config, null, 2) + "\n";
2162
+ }
1142
2163
 
1143
- export { DEFAULT_KIND_OFFSET, DiscoveryService, ElisymClient, ElisymIdentity, KIND_APP_HANDLER, KIND_GIFT_WRAP, KIND_JOB_FEEDBACK, KIND_JOB_REQUEST, KIND_JOB_REQUEST_BASE, KIND_JOB_RESULT, KIND_JOB_RESULT_BASE, KIND_PING, KIND_PONG, LAMPORTS_PER_SOL, MarketplaceService, MessagingService, NostrPool, PROTOCOL_FEE_BPS, PROTOCOL_TREASURY, PaymentService, RELAYS, formatSol, jobRequestKind, jobResultKind, makeNjumpUrl, timeAgo, toDTag, truncateKey };
2164
+ export { BoundedSet, DEFAULTS, DEFAULT_KIND_OFFSET, DiscoveryService, ElisymClient, ElisymIdentity, KIND_APP_HANDLER, KIND_GIFT_WRAP, KIND_JOB_FEEDBACK, KIND_JOB_REQUEST, KIND_JOB_REQUEST_BASE, KIND_JOB_RESULT, KIND_JOB_RESULT_BASE, KIND_PING, KIND_PONG, LAMPORTS_PER_SOL, LIMITS, MarketplaceService, MediaService, MessagingService, NostrPool, PROTOCOL_FEE_BPS, PROTOCOL_TREASURY, RELAYS, SolanaPaymentStrategy, assertExpiry, assertLamports, calculateProtocolFee, formatSol, jobRequestKind, jobResultKind, nip44Decrypt, nip44Encrypt, serializeConfig, timeAgo, toDTag, truncateKey, validateAgentName, validateExpiry };
1144
2165
  //# sourceMappingURL=index.js.map
1145
2166
  //# sourceMappingURL=index.js.map