@elisym/sdk 0.1.3 → 0.2.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.
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,10 +495,36 @@ 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);
@@ -264,12 +555,13 @@ function buildAgentsFromEvents(events, network) {
264
555
  }
265
556
  const agentMap = /* @__PURE__ */ new Map();
266
557
  for (const [pubkey, acc] of accumMap) {
267
- const supportedKinds = [];
558
+ const kindsSet = /* @__PURE__ */ new Set();
268
559
  for (const e of acc.entries) {
269
560
  for (const k of e.kTags) {
270
- if (!supportedKinds.includes(k)) supportedKinds.push(k);
561
+ kindsSet.add(k);
271
562
  }
272
563
  }
564
+ const supportedKinds = [...kindsSet];
273
565
  agentMap.set(pubkey, {
274
566
  pubkey: acc.pubkey,
275
567
  npub: acc.npub,
@@ -285,23 +577,28 @@ var DiscoveryService = class {
285
577
  constructor(pool) {
286
578
  this.pool = pool;
287
579
  }
288
- // Instance-level set — avoids module-level state leak across clients
289
- allSeenAgents = /* @__PURE__ */ new Set();
290
580
  /** Count elisym agents (kind:31990 with "elisym" tag). */
291
581
  async fetchAllAgentCount() {
292
582
  const events = await this.pool.querySync({
293
583
  kinds: [KIND_APP_HANDLER],
294
584
  "#t": ["elisym"]
295
585
  });
586
+ const uniquePubkeys = /* @__PURE__ */ new Set();
296
587
  for (const event of events) {
297
- this.allSeenAgents.add(event.pubkey);
588
+ if (!verifyEvent(event)) {
589
+ continue;
590
+ }
591
+ uniquePubkeys.add(event.pubkey);
298
592
  }
299
- return this.allSeenAgents.size;
593
+ return uniquePubkeys.size;
300
594
  }
301
595
  /**
302
596
  * Fetch a single page of elisym agents with relay-side pagination.
303
597
  * Uses `until` cursor for Nostr cursor-based pagination.
304
- * 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.
305
602
  */
306
603
  async fetchAgentsPage(network = "devnet", limit = 20, until) {
307
604
  const filter = {
@@ -321,21 +618,24 @@ var DiscoveryService = class {
321
618
  }
322
619
  }
323
620
  const agentMap = buildAgentsFromEvents(events, network);
324
- const agents = Array.from(agentMap.values()).sort(
325
- (a, b) => b.lastSeen - a.lastSeen
326
- );
621
+ const agents = Array.from(agentMap.values()).sort((a, b) => b.lastSeen - a.lastSeen);
327
622
  return { agents, oldestCreatedAt, rawEventCount };
328
623
  }
329
624
  /** Enrich agents with kind:0 metadata (name, picture, about). Mutates in place and returns the same array. */
330
625
  async enrichWithMetadata(agents) {
331
626
  const pubkeys = agents.map((a) => a.pubkey);
332
- if (pubkeys.length === 0) return agents;
627
+ if (pubkeys.length === 0) {
628
+ return agents;
629
+ }
333
630
  const metaEvents = await this.pool.queryBatched(
334
631
  { kinds: [0] },
335
632
  pubkeys
336
633
  );
337
634
  const latestMeta = /* @__PURE__ */ new Map();
338
635
  for (const ev of metaEvents) {
636
+ if (!verifyEvent(ev)) {
637
+ continue;
638
+ }
339
639
  const prev = latestMeta.get(ev.pubkey);
340
640
  if (!prev || ev.created_at > prev.created_at) {
341
641
  latestMeta.set(ev.pubkey, ev);
@@ -344,12 +644,20 @@ var DiscoveryService = class {
344
644
  const agentLookup = new Map(agents.map((a) => [a.pubkey, a]));
345
645
  for (const [pubkey, ev] of latestMeta) {
346
646
  const agent = agentLookup.get(pubkey);
347
- if (!agent) continue;
647
+ if (!agent) {
648
+ continue;
649
+ }
348
650
  try {
349
651
  const meta = JSON.parse(ev.content);
350
- if (meta.picture) agent.picture = meta.picture;
351
- if (meta.name) agent.name = meta.name;
352
- 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
+ }
353
661
  } catch {
354
662
  }
355
663
  }
@@ -361,9 +669,12 @@ var DiscoveryService = class {
361
669
  kinds: [KIND_APP_HANDLER],
362
670
  "#t": ["elisym"]
363
671
  };
364
- if (limit !== void 0) filter.limit = limit;
672
+ if (limit !== void 0) {
673
+ filter.limit = limit;
674
+ }
365
675
  const events = await this.pool.querySync(filter);
366
676
  const agentMap = buildAgentsFromEvents(events, network);
677
+ const agents = Array.from(agentMap.values()).sort((a, b) => b.lastSeen - a.lastSeen);
367
678
  const agentPubkeys = Array.from(agentMap.keys());
368
679
  if (agentPubkeys.length > 0) {
369
680
  const activitySince = Math.floor(Date.now() / 1e3) - 24 * 60 * 60;
@@ -376,33 +687,65 @@ var DiscoveryService = class {
376
687
  }
377
688
  }
378
689
  resultKinds.add(jobResultKind(DEFAULT_KIND_OFFSET));
379
- const activityEvents = await this.pool.queryBatched(
380
- {
381
- kinds: [...resultKinds, KIND_JOB_FEEDBACK],
382
- since: activitySince
383
- },
384
- agentPubkeys
385
- );
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
+ ]);
386
700
  for (const ev of activityEvents) {
701
+ if (!verifyEvent(ev)) {
702
+ continue;
703
+ }
387
704
  const agent = agentMap.get(ev.pubkey);
388
705
  if (agent && ev.created_at > agent.lastSeen) {
389
706
  agent.lastSeen = ev.created_at;
390
707
  }
391
708
  }
709
+ agents.sort((a, b) => b.lastSeen - a.lastSeen);
392
710
  }
393
- const agents = Array.from(agentMap.values()).sort(
394
- (a, b) => b.lastSeen - a.lastSeen
395
- );
396
- await this.enrichWithMetadata(agents);
397
711
  return agents;
398
712
  }
399
- /** 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
+ */
400
718
  async publishCapability(identity, card, kinds = [KIND_JOB_REQUEST]) {
401
719
  if (!card.payment?.address) {
402
720
  throw new Error(
403
721
  "Cannot publish capability without a payment address. Connect a wallet before publishing."
404
722
  );
405
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
+ }
406
749
  const tags = [
407
750
  ["d", toDTag(card.name)],
408
751
  ["t", "elisym"],
@@ -418,13 +761,28 @@ var DiscoveryService = class {
418
761
  },
419
762
  identity.secretKey
420
763
  );
421
- await this.pool.publish(event);
764
+ await this.pool.publishAll(event);
422
765
  return event.id;
423
766
  }
424
767
  /** Publish a Nostr profile (kind:0) as a provider. */
425
- 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
+ }
426
779
  const content = { name, about };
427
- if (picture) content.picture = picture;
780
+ if (picture) {
781
+ content.picture = picture;
782
+ }
783
+ if (banner) {
784
+ content.banner = banner;
785
+ }
428
786
  const event = finalizeEvent(
429
787
  {
430
788
  kind: 0,
@@ -434,7 +792,7 @@ var DiscoveryService = class {
434
792
  },
435
793
  identity.secretKey
436
794
  );
437
- await this.pool.publish(event);
795
+ await this.pool.publishAll(event);
438
796
  return event.id;
439
797
  }
440
798
  /**
@@ -461,20 +819,40 @@ var DiscoveryService = class {
461
819
  return event.id;
462
820
  }
463
821
  };
464
- function isEncrypted(event) {
465
- return event.tags.some((t) => t[0] === "encrypted" && t[1] === "nip44");
466
- }
467
- function nip44Encrypt(plaintext, secretKey, recipientPubkey) {
468
- const conversationKey = nip44.v2.utils.getConversationKey(secretKey, recipientPubkey);
822
+ function nip44Encrypt(plaintext, senderSk, recipientPubkey) {
823
+ const conversationKey = nip44.v2.utils.getConversationKey(senderSk, recipientPubkey);
469
824
  return nip44.v2.encrypt(plaintext, conversationKey);
470
825
  }
471
- function nip44Decrypt(ciphertext, secretKey, senderPubkey) {
472
- const conversationKey = nip44.v2.utils.getConversationKey(secretKey, senderPubkey);
826
+ function nip44Decrypt(ciphertext, receiverSk, senderPubkey) {
827
+ const conversationKey = nip44.v2.utils.getConversationKey(receiverSk, senderPubkey);
473
828
  return nip44.v2.decrypt(ciphertext, conversationKey);
474
829
  }
830
+
831
+ // src/services/marketplace.ts
832
+ function isEncrypted(event) {
833
+ return event.tags.some((t) => t[0] === "encrypted" && t[1] === "nip44");
834
+ }
475
835
  function resolveRequestId(event) {
476
836
  return event.tags.find((t) => t[0] === "e")?.[1];
477
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
+ }
478
856
  var MarketplaceService = class {
479
857
  constructor(pool) {
480
858
  this.pool = pool;
@@ -484,11 +862,21 @@ var MarketplaceService = class {
484
862
  if (!options.input) {
485
863
  throw new Error("Job input must not be empty.");
486
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
+ }
487
876
  const plaintext = options.input;
488
877
  const encrypted = options.providerPubkey ? nip44Encrypt(plaintext, identity.secretKey, options.providerPubkey) : plaintext;
489
- const iValue = options.providerPubkey ? "encrypted" : "";
490
878
  const tags = [
491
- ["i", iValue, "text"],
879
+ ["i", options.providerPubkey ? "encrypted" : "text", "text"],
492
880
  ["t", options.capability],
493
881
  ["t", "elisym"],
494
882
  ["output", "text/plain"]
@@ -512,80 +900,148 @@ var MarketplaceService = class {
512
900
  }
513
901
  /**
514
902
  * Subscribe to job updates (feedback + results) for a given job.
515
- * 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.
516
905
  */
517
- subscribeToJobUpdates(jobEventId, providerPubkey, customerPublicKey, callbacks, timeoutMs = 12e4, customerSecretKey, kindOffsets) {
518
- 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
+ }
519
921
  const resultKinds = offsets.map(jobResultKind);
520
- const since = Math.floor(Date.now() / 1e3) - 5;
922
+ const since = since_ ?? Math.floor(Date.now() / 1e3) - 30;
521
923
  const subs = [];
522
924
  let resolved = false;
523
925
  let resultDelivered = false;
524
926
  let timer;
525
927
  const done = () => {
526
928
  resolved = true;
527
- if (timer) clearTimeout(timer);
528
- 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
+ }
529
938
  };
530
939
  const decryptResult = (ev) => {
531
- if (customerSecretKey && isEncrypted(ev)) {
940
+ if (isEncrypted(ev)) {
941
+ if (!custSk) {
942
+ return null;
943
+ }
532
944
  try {
533
- return nip44Decrypt(ev.content, customerSecretKey, ev.pubkey);
945
+ return nip44Decrypt(ev.content, custSk, ev.pubkey);
534
946
  } catch {
535
- return ev.content;
947
+ return null;
536
948
  }
537
949
  }
538
950
  return ev.content;
539
951
  };
540
952
  const handleResult = (ev) => {
541
- if (resolved || resultDelivered) return;
542
- 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
+ }
543
970
  resultDelivered = true;
544
- callbacks.onResult?.(decryptResult(ev), ev.id);
545
- done();
546
- };
547
- const feedbackSub = this.pool.subscribe(
548
- {
549
- kinds: [KIND_JOB_FEEDBACK],
550
- "#e": [jobEventId],
551
- since
552
- },
553
- (ev) => {
554
- if (resolved) return;
555
- if (providerPubkey && ev.pubkey !== providerPubkey) return;
556
- const statusTag = ev.tags.find((t) => t[0] === "status");
557
- if (statusTag?.[1] === "payment-required") {
558
- const amtTag = ev.tags.find((t) => t[0] === "amount");
559
- const amt = amtTag?.[1] ? parseInt(amtTag[1], 10) : 0;
560
- const paymentReq = amtTag?.[2];
561
- callbacks.onFeedback?.("payment-required", amt, paymentReq);
562
- }
971
+ try {
972
+ cb.onResult?.(content, ev.id);
973
+ } catch {
974
+ } finally {
975
+ done();
563
976
  }
564
- );
565
- subs.push(feedbackSub);
566
- const resultSub = this.pool.subscribe(
567
- {
568
- kinds: resultKinds,
569
- "#e": [jobEventId],
570
- since
571
- },
572
- handleResult
573
- );
574
- subs.push(resultSub);
575
- const resultSub2 = this.pool.subscribe(
576
- {
577
- kinds: resultKinds,
578
- "#p": [customerPublicKey],
579
- "#e": [jobEventId],
580
- since
581
- },
582
- handleResult
583
- );
584
- 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
+ }
585
1038
  timer = setTimeout(() => {
586
1039
  if (!resolved) {
587
1040
  done();
588
- 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
+ }
589
1045
  }
590
1046
  }, timeoutMs);
591
1047
  return done;
@@ -607,7 +1063,7 @@ var MarketplaceService = class {
607
1063
  },
608
1064
  identity.secretKey
609
1065
  );
610
- await this.pool.publish(event);
1066
+ await this.pool.publishAll(event);
611
1067
  }
612
1068
  /** Submit rating feedback for a job. */
613
1069
  async submitFeedback(identity, jobEventId, providerPubkey, positive, capability) {
@@ -618,7 +1074,9 @@ var MarketplaceService = class {
618
1074
  ["rating", positive ? "1" : "0"],
619
1075
  ["t", "elisym"]
620
1076
  ];
621
- if (capability) tags.push(["t", capability]);
1077
+ if (capability) {
1078
+ tags.push(["t", capability]);
1079
+ }
622
1080
  const event = finalizeEvent(
623
1081
  {
624
1082
  kind: KIND_JOB_FEEDBACK,
@@ -628,31 +1086,67 @@ var MarketplaceService = class {
628
1086
  },
629
1087
  identity.secretKey
630
1088
  );
631
- await this.pool.publish(event);
1089
+ await this.pool.publishAll(event);
632
1090
  }
633
1091
  // --- Provider methods ---
634
- /** 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
+ */
635
1098
  subscribeToJobRequests(identity, kinds, onRequest) {
636
1099
  return this.pool.subscribe(
637
1100
  {
638
1101
  kinds,
639
1102
  "#p": [identity.publicKey],
640
- since: Math.floor(Date.now() / 1e3)
1103
+ "#t": ["elisym"],
1104
+ since: Math.floor(Date.now() / 1e3) - 5
641
1105
  },
642
- 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
+ }
643
1121
  );
644
1122
  }
645
1123
  /** Submit a job result with NIP-44 encrypted content. Result kind is derived from the request kind. */
646
1124
  async submitJobResult(identity, requestEvent, content, amount) {
647
- const encrypted = nip44Encrypt(content, identity.secretKey, requestEvent.pubkey);
648
- 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;
649
1140
  const tags = [
650
1141
  ["e", requestEvent.id],
651
1142
  ["p", requestEvent.pubkey],
652
- ["t", "elisym"],
653
- ["encrypted", "nip44"]
1143
+ ["t", "elisym"]
654
1144
  ];
655
- if (amount != null) {
1145
+ if (shouldEncrypt) {
1146
+ tags.push(["encrypted", "nip44"]);
1147
+ }
1148
+ if (amount !== null && amount !== void 0) {
1149
+ assertLamports(amount, "result amount");
656
1150
  tags.push(["amount", String(amount)]);
657
1151
  }
658
1152
  const event = finalizeEvent(
@@ -660,15 +1154,50 @@ var MarketplaceService = class {
660
1154
  kind: resultKind,
661
1155
  created_at: Math.floor(Date.now() / 1e3),
662
1156
  tags,
663
- content: encrypted
1157
+ content: resultContent
664
1158
  },
665
1159
  identity.secretKey
666
1160
  );
667
- await this.pool.publish(event);
1161
+ await this.pool.publishAll(event);
668
1162
  return event.id;
669
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
+ }
670
1185
  /** Submit payment-required feedback with a payment request. */
671
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
+ }
672
1201
  const event = finalizeEvent(
673
1202
  {
674
1203
  kind: KIND_JOB_FEEDBACK,
@@ -684,74 +1213,193 @@ var MarketplaceService = class {
684
1213
  },
685
1214
  identity.secretKey
686
1215
  );
687
- 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;
688
1311
  }
689
1312
  // --- Query methods ---
690
- /** 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
+ */
691
1318
  async fetchRecentJobs(agentPubkeys, limit, since, kindOffsets) {
692
1319
  const offsets = kindOffsets ?? [DEFAULT_KIND_OFFSET];
1320
+ if (offsets.length === 0) {
1321
+ throw new Error("kindOffsets must not be empty.");
1322
+ }
693
1323
  const requestKinds = offsets.map(jobRequestKind);
694
1324
  const resultKinds = offsets.map(jobResultKind);
695
1325
  const reqFilter = {
696
1326
  kinds: requestKinds,
697
1327
  "#t": ["elisym"],
698
- ...limit != null && { limit },
699
- ...since != null && { since }
1328
+ ...limit !== null && limit !== void 0 && { limit },
1329
+ ...since !== null && since !== void 0 && { since }
700
1330
  };
701
- const requests = await this.pool.querySync(reqFilter);
1331
+ const rawRequests = await this.pool.querySync(reqFilter);
1332
+ const requests = rawRequests.filter(verifyEvent);
702
1333
  const requestIds = requests.map((r) => r.id);
703
1334
  let results = [];
704
1335
  let feedbacks = [];
705
1336
  if (requestIds.length > 0) {
706
- const [resultArrays, feedbackArrays] = await Promise.all([
707
- this.pool.queryBatchedByTag(
708
- { kinds: resultKinds },
709
- "e",
710
- requestIds
711
- ),
712
- this.pool.queryBatchedByTag(
713
- { kinds: [KIND_JOB_FEEDBACK] },
714
- "e",
715
- requestIds
716
- )
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)
717
1340
  ]);
718
- results = resultArrays;
719
- feedbacks = feedbackArrays;
1341
+ results = rawResults.filter(verifyEvent);
1342
+ feedbacks = rawFeedbacks.filter(verifyEvent);
720
1343
  }
721
1344
  const targetedAgentByRequest = /* @__PURE__ */ new Map();
722
1345
  for (const req of requests) {
723
1346
  const pTag = req.tags.find((t) => t[0] === "p");
724
- if (pTag?.[1]) targetedAgentByRequest.set(req.id, pTag[1]);
1347
+ if (pTag?.[1]) {
1348
+ targetedAgentByRequest.set(req.id, pTag[1]);
1349
+ }
725
1350
  }
726
1351
  const resultsByRequest = /* @__PURE__ */ new Map();
727
1352
  for (const r of results) {
728
1353
  const reqId = resolveRequestId(r);
729
- if (!reqId) continue;
1354
+ if (!reqId) {
1355
+ continue;
1356
+ }
730
1357
  const targeted = targetedAgentByRequest.get(reqId);
731
- if (targeted && r.pubkey !== targeted) continue;
1358
+ if (targeted && r.pubkey !== targeted) {
1359
+ continue;
1360
+ }
732
1361
  const existing = resultsByRequest.get(reqId);
733
- if (!existing || targeted && r.pubkey === targeted) {
1362
+ if (!existing || r.created_at > existing.created_at) {
734
1363
  resultsByRequest.set(reqId, r);
735
1364
  }
736
1365
  }
737
1366
  const feedbackByRequest = /* @__PURE__ */ new Map();
738
1367
  for (const f of feedbacks) {
739
1368
  const reqId = resolveRequestId(f);
740
- if (!reqId) continue;
1369
+ if (!reqId) {
1370
+ continue;
1371
+ }
741
1372
  const targeted = targetedAgentByRequest.get(reqId);
742
- if (targeted && f.pubkey !== targeted) continue;
1373
+ if (targeted && f.pubkey !== targeted) {
1374
+ continue;
1375
+ }
743
1376
  const existing = feedbackByRequest.get(reqId);
744
- if (!existing || targeted && f.pubkey === targeted) {
1377
+ if (!existing || f.created_at > existing.created_at) {
745
1378
  feedbackByRequest.set(reqId, f);
746
1379
  }
747
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
+ }
748
1394
  const jobs = [];
749
1395
  for (const req of requests) {
750
1396
  const result = resultsByRequest.get(req.id);
751
1397
  const feedback = feedbackByRequest.get(req.id);
752
1398
  const jobAgentPubkey = result?.pubkey ?? feedback?.pubkey;
753
1399
  if (agentPubkeys && agentPubkeys.size > 0 && jobAgentPubkey) {
754
- if (!agentPubkeys.has(jobAgentPubkey)) continue;
1400
+ if (!agentPubkeys.has(jobAgentPubkey)) {
1401
+ continue;
1402
+ }
755
1403
  }
756
1404
  const capability = req.tags.find((t) => t[0] === "t" && t[1] !== "elisym")?.[1];
757
1405
  const bid = req.tags.find((t) => t[0] === "bid")?.[1];
@@ -761,11 +1409,9 @@ var MarketplaceService = class {
761
1409
  if (result) {
762
1410
  status = "success";
763
1411
  const amtTag = result.tags.find((t) => t[0] === "amount");
764
- if (amtTag?.[1]) amount = parseInt(amtTag[1], 10);
1412
+ amount = safeParseInt(amtTag?.[1]);
765
1413
  }
766
- const allFeedbacksForReq = feedbacks.filter(
767
- (f) => resolveRequestId(f) === req.id
768
- );
1414
+ const allFeedbacksForReq = feedbacksByRequestId.get(req.id) ?? [];
769
1415
  for (const fb of allFeedbacksForReq) {
770
1416
  const txTag = fb.tags.find((t) => t[0] === "tx");
771
1417
  if (txTag?.[1]) {
@@ -779,13 +1425,13 @@ var MarketplaceService = class {
779
1425
  if (statusTag?.[1]) {
780
1426
  const isTargeted = targetedAgentByRequest.has(req.id);
781
1427
  if (statusTag[1] === "payment-required" && !bid && !isTargeted) ; else {
782
- status = statusTag[1];
1428
+ status = toJobStatus(statusTag[1]);
783
1429
  }
784
1430
  }
785
1431
  }
786
1432
  if (!amount) {
787
1433
  const amtTag = feedback.tags.find((t) => t[0] === "amount");
788
- if (amtTag?.[1]) amount = parseInt(amtTag[1], 10);
1434
+ amount = safeParseInt(amtTag?.[1]);
789
1435
  }
790
1436
  }
791
1437
  jobs.push({
@@ -793,7 +1439,7 @@ var MarketplaceService = class {
793
1439
  customer: req.pubkey,
794
1440
  agentPubkey: jobAgentPubkey,
795
1441
  capability,
796
- bid: bid ? parseInt(bid, 10) : void 0,
1442
+ bid: safeParseInt(bid),
797
1443
  status,
798
1444
  result: result?.content,
799
1445
  resultEventId: result?.id,
@@ -812,96 +1458,285 @@ var MarketplaceService = class {
812
1458
  "#t": ["elisym"],
813
1459
  since: Math.floor(Date.now() / 1e3)
814
1460
  },
815
- onEvent
1461
+ (event) => {
1462
+ if (!verifyEvent(event)) {
1463
+ return;
1464
+ }
1465
+ onEvent(event);
1466
+ }
816
1467
  );
817
1468
  }
818
1469
  };
819
- var MessagingService = class _MessagingService {
820
- // 30s
821
- constructor(pool) {
822
- this.pool = pool;
823
- 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;
824
1475
  }
825
- sessionIdentity;
826
- pingCache = /* @__PURE__ */ new Map();
827
- // pubkey → timestamp of last online result
828
- pendingPings = /* @__PURE__ */ new Map();
829
- // dedup in-flight pings
830
- static PING_CACHE_TTL = 3e4;
831
1476
  /**
832
- * Ping an agent via ephemeral Nostr events (kind 20200/20201).
833
- * Uses a persistent session identity to avoid relay rate-limiting.
834
- * Publishes to ALL relays for maximum delivery reliability.
835
- * 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.
836
1484
  */
837
- async pingAgent(agentPubkey, timeoutMs = 15e3, signal) {
838
- const cachedAt = this.pingCache.get(agentPubkey);
839
- if (cachedAt && Date.now() - cachedAt < _MessagingService.PING_CACHE_TTL) {
840
- console.log(`[ping] cache hit for ${agentPubkey.slice(0, 8)}: online`);
841
- 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);
842
1529
  }
843
- const pending = this.pendingPings.get(agentPubkey);
844
- if (pending) {
845
- console.log(`[ping] dedup: reusing in-flight ping for ${agentPubkey.slice(0, 8)}`);
846
- 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.");
847
1539
  }
848
- const promise = this._doPing(agentPubkey, timeoutMs, signal);
849
- this.pendingPings.set(agentPubkey, promise);
850
- promise.finally(() => this.pendingPings.delete(agentPubkey));
851
- return promise;
1540
+ this.items = new Array(maxSize);
852
1541
  }
853
- async _doPing(agentPubkey, timeoutMs, signal) {
854
- const sk = this.sessionIdentity.secretKey;
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) {
1665
+ const sk = this.sessionIdentity.secretKey;
855
1666
  const pk = this.sessionIdentity.publicKey;
856
1667
  const nonce = crypto.getRandomValues(new Uint8Array(16)).reduce((s, b) => s + b.toString(16).padStart(2, "0"), "");
857
- const shortNonce = nonce.slice(0, 8);
858
- const shortAgent = agentPubkey.slice(0, 8);
859
- console.log(`[ping] \u2192 ping ${shortAgent} nonce=${shortNonce}`);
860
1668
  if (signal?.aborted) {
861
1669
  return { online: false, identity: null };
862
1670
  }
863
- return new Promise(async (resolve) => {
864
- let resolved = false;
865
- const done = (online, reason) => {
866
- if (resolved) return;
867
- 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) {
868
1684
  clearTimeout(timer);
869
- sub.close();
870
- signal?.removeEventListener("abort", onAbort);
871
- console.log(`[ping] ${online ? "\u2713 ONLINE" : "\u2717 OFFLINE"} agent=${shortAgent} nonce=${shortNonce}${reason ? ` (${reason})` : ""}`);
872
- if (online) this.pingCache.set(agentPubkey, Date.now());
873
- resolve({ online, identity: online ? this.sessionIdentity : null });
874
- };
875
- const onAbort = () => done(false, "aborted");
876
- signal?.addEventListener("abort", onAbort);
877
- const sub = this.pool.subscribe(
878
- { kinds: [KIND_PONG], "#p": [pk] },
879
- (ev) => {
880
- try {
881
- const msg = JSON.parse(ev.content);
882
- if (msg.type === "elisym_pong" && msg.nonce === nonce) {
883
- console.log(`[ping] \u2190 pong from ${ev.pubkey.slice(0, 8)} nonce=${shortNonce}`);
884
- done(true, "pong matched");
885
- }
886
- } 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);
887
1695
  }
888
1696
  }
889
- );
890
- const pingEvent = finalizeEvent(
891
- {
892
- kind: KIND_PING,
893
- created_at: Math.floor(Date.now() / 1e3),
894
- tags: [["p", agentPubkey]],
895
- content: JSON.stringify({ type: "elisym_ping", nonce })
896
- },
897
- sk
898
- );
899
- this.pool.publishAll(pingEvent).then(() => console.log(`[ping] \u2713 published nonce=${shortNonce}`)).catch((err) => {
900
- console.error(`[ping] \u2717 publish failed nonce=${shortNonce}`, err);
901
- 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
+ }
902
1718
  });
903
- 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);
904
1738
  });
1739
+ return promise;
905
1740
  }
906
1741
  /**
907
1742
  * Subscribe to incoming ephemeral ping events (kind 20200).
@@ -911,9 +1746,12 @@ var MessagingService = class _MessagingService {
911
1746
  return this.pool.subscribe(
912
1747
  { kinds: [KIND_PING], "#p": [identity.publicKey] },
913
1748
  (ev) => {
1749
+ if (!verifyEvent(ev)) {
1750
+ return;
1751
+ }
914
1752
  try {
915
1753
  const msg = JSON.parse(ev.content);
916
- if (msg.type === "elisym_ping" && msg.nonce) {
1754
+ if (msg.type === "elisym_ping" && typeof msg.nonce === "string" && msg.nonce.length === 32) {
917
1755
  onPing(ev.pubkey, msg.nonce);
918
1756
  }
919
1757
  } catch {
@@ -936,11 +1774,15 @@ var MessagingService = class _MessagingService {
936
1774
  }
937
1775
  /** Send a NIP-17 DM. */
938
1776
  async sendMessage(identity, recipientPubkey, content) {
939
- const wrap = nip17.wrapEvent(
940
- identity.secretKey,
941
- { publicKey: recipientPubkey },
942
- content
943
- );
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);
944
1786
  await this.pool.publish(wrap);
945
1787
  }
946
1788
  /** Fetch historical NIP-17 DMs from relays. Returns decrypted messages sorted by time. */
@@ -950,12 +1792,17 @@ var MessagingService = class _MessagingService {
950
1792
  "#p": [identity.publicKey],
951
1793
  since
952
1794
  });
953
- const seen = /* @__PURE__ */ new Set();
1795
+ const seen = new BoundedSet(1e4);
954
1796
  const messages = [];
955
1797
  for (const ev of events) {
956
1798
  try {
957
1799
  const rumor = nip59.unwrapEvent(ev, identity.secretKey);
958
- if (seen.has(rumor.id)) continue;
1800
+ if (rumor.kind !== 14) {
1801
+ continue;
1802
+ }
1803
+ if (seen.has(rumor.id)) {
1804
+ continue;
1805
+ }
959
1806
  seen.add(rumor.id);
960
1807
  messages.push({
961
1808
  senderPubkey: rumor.pubkey,
@@ -970,134 +1817,312 @@ var MessagingService = class _MessagingService {
970
1817
  }
971
1818
  /** Subscribe to incoming NIP-17 DMs. */
972
1819
  subscribeToMessages(identity, onMessage, since) {
973
- const seen = /* @__PURE__ */ new Set();
1820
+ const seen = new BoundedSet(1e4);
974
1821
  const filter = {
975
1822
  kinds: [KIND_GIFT_WRAP],
976
1823
  "#p": [identity.publicKey]
977
1824
  };
978
- if (since !== void 0) filter.since = since;
979
- return this.pool.subscribe(
980
- filter,
981
- (ev) => {
982
- try {
983
- const rumor = nip59.unwrapEvent(ev, identity.secretKey);
984
- if (seen.has(rumor.id)) return;
985
- seen.add(rumor.id);
986
- onMessage(rumor.pubkey, rumor.content, rumor.created_at, rumor.id);
987
- } 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;
988
1836
  }
1837
+ seen.add(rumor.id);
1838
+ onMessage(rumor.pubkey, rumor.content, rumor.created_at, rumor.id);
1839
+ } catch {
989
1840
  }
990
- );
1841
+ });
991
1842
  }
992
1843
  };
993
- var PaymentService = class _PaymentService {
994
- /**
995
- * Calculate protocol fee using Decimal basis-point math (no floats).
996
- * Returns ceil(amount * PROTOCOL_FEE_BPS / 10000).
997
- */
998
- static calculateProtocolFee(amount) {
999
- if (amount === 0) return 0;
1000
- 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;
1001
1851
  }
1002
- /**
1003
- * Validate that a payment request has the correct recipient and protocol fee.
1004
- * Returns an error message if invalid, null if OK.
1005
- */
1006
- static validatePaymentFee(requestJson, expectedRecipient) {
1007
- 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
+ });
1008
1858
  try {
1009
- data = JSON.parse(requestJson);
1010
- } catch (e) {
1011
- 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);
1012
1868
  }
1013
- if (expectedRecipient && data.recipient !== expectedRecipient) {
1014
- 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());
1902
+ }
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());
1015
1937
  }
1016
- if (data.created_at > 0 && data.expiry_secs > 0) {
1017
- const elapsed = Math.floor(Date.now() / 1e3) - data.created_at;
1018
- if (elapsed > data.expiry_secs) {
1019
- return `Payment request expired (created ${data.created_at}, expiry ${data.expiry_secs}s).`;
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
+ );
1020
1948
  }
1949
+ throw err;
1021
1950
  }
1022
- const expectedFee = _PaymentService.calculateProtocolFee(data.amount);
1023
- const { fee_address, fee_amount } = data;
1024
- if (fee_address && fee_amount && fee_amount > 0) {
1025
- if (fee_address !== PROTOCOL_TREASURY) {
1026
- 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);
1027
1966
  }
1028
- if (fee_amount !== expectedFee) {
1029
- 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
+ }
1030
2022
  }
1031
- 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");
1032
2040
  }
1033
- if (!fee_address && !fee_amount) {
1034
- 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 {
1035
2045
  }
1036
- return `Invalid fee params in payment request. Expected fee: ${expectedFee} lamports to ${PROTOCOL_TREASURY}.`;
2046
+ this.pool = new SimplePool();
1037
2047
  }
1038
2048
  /**
1039
- * Build a Solana transaction from a payment request.
1040
- * The caller must sign and send via wallet adapter.
2049
+ * Lightweight connectivity probe. Returns true if at least one relay responds.
1041
2050
  */
1042
- static buildPaymentTransaction(payerPubkey, paymentRequest) {
1043
- const recipient = new PublicKey(paymentRequest.recipient);
1044
- const reference = new PublicKey(paymentRequest.reference);
1045
- const feeAddress = paymentRequest.fee_address ? new PublicKey(paymentRequest.fee_address) : null;
1046
- const feeAmount = paymentRequest.fee_amount ?? 0;
1047
- const providerAmount = feeAddress && feeAmount > 0 ? new Decimal(paymentRequest.amount).minus(feeAmount).toNumber() : paymentRequest.amount;
1048
- const transferIx = SystemProgram.transfer({
1049
- fromPubkey: payerPubkey,
1050
- toPubkey: recipient,
1051
- lamports: providerAmount
1052
- });
1053
- transferIx.keys.push({
1054
- pubkey: reference,
1055
- isSigner: false,
1056
- 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(() => {
1057
2055
  });
1058
- const tx = new Transaction().add(transferIx);
1059
- if (feeAddress && feeAmount > 0) {
1060
- tx.add(
1061
- SystemProgram.transfer({
1062
- fromPubkey: payerPubkey,
1063
- toPubkey: feeAddress,
1064
- lamports: feeAmount
2056
+ try {
2057
+ await Promise.race([
2058
+ query,
2059
+ new Promise((_, reject) => {
2060
+ timer = setTimeout(() => reject(new Error("probe timeout")), timeoutMs);
1065
2061
  })
1066
- );
2062
+ ]);
2063
+ return true;
2064
+ } catch {
2065
+ return false;
2066
+ } finally {
2067
+ clearTimeout(timer);
1067
2068
  }
1068
- return tx;
1069
2069
  }
1070
- /**
1071
- * Create a payment request with auto-calculated protocol fee.
1072
- * Used by providers to generate payment requests for customers.
1073
- */
1074
- static createPaymentRequest(recipientAddress, amount, expirySecs = 600) {
1075
- const feeAmount = _PaymentService.calculateProtocolFee(amount);
1076
- const reference = PublicKey.unique().toBase58();
1077
- return {
1078
- recipient: recipientAddress,
1079
- amount,
1080
- reference,
1081
- fee_address: PROTOCOL_TREASURY,
1082
- fee_amount: feeAmount,
1083
- created_at: Math.floor(Date.now() / 1e3),
1084
- expiry_secs: expirySecs
1085
- };
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();
1086
2103
  }
1087
2104
  };
1088
2105
  function formatSol(lamports) {
1089
- const sol = new Decimal(lamports).div(LAMPORTS_PER_SOL);
1090
- if (sol.gte(1e6)) return `${sol.idiv(1e6)}m SOL`;
1091
- 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
+ }
1092
2113
  return `${compactSol(sol)} SOL`;
1093
2114
  }
1094
2115
  function compactSol(sol) {
1095
- if (sol.isZero()) return "0";
1096
- 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
+ }
1097
2122
  const maxFrac = 9;
1098
2123
  for (let d = 1; d <= maxFrac; d++) {
1099
2124
  const s = sol.toFixed(d);
1100
- if (new Decimal(s).eq(sol)) {
2125
+ if (new Decimal2(s).eq(sol)) {
1101
2126
  return s.replace(/0+$/, "").replace(/\.$/, "");
1102
2127
  }
1103
2128
  }
@@ -1105,43 +2130,37 @@ function compactSol(sol) {
1105
2130
  }
1106
2131
  function timeAgo(unix) {
1107
2132
  const seconds = Math.max(0, Math.floor(Date.now() / 1e3 - unix));
1108
- if (seconds < 60) return `${seconds}s ago`;
2133
+ if (seconds < 60) {
2134
+ return `${seconds}s ago`;
2135
+ }
1109
2136
  const minutes = Math.floor(seconds / 60);
1110
- if (minutes < 60) return `${minutes}m ago`;
2137
+ if (minutes < 60) {
2138
+ return `${minutes}m ago`;
2139
+ }
1111
2140
  const hours = Math.floor(minutes / 60);
1112
- if (hours < 24) return `${hours}h ago`;
2141
+ if (hours < 24) {
2142
+ return `${hours}h ago`;
2143
+ }
1113
2144
  const days = Math.floor(hours / 24);
1114
2145
  return `${days}d ago`;
1115
2146
  }
1116
2147
  function truncateKey(hex, chars = 6) {
1117
- if (hex.length <= chars * 2) return hex;
2148
+ if (hex.length <= chars * 2) {
2149
+ return hex;
2150
+ }
1118
2151
  return `${hex.slice(0, chars)}...${hex.slice(-chars)}`;
1119
2152
  }
1120
- function makeNjumpUrl(eventId, relays = RELAYS) {
1121
- const nevent = nip19.neventEncode({
1122
- id: eventId,
1123
- relays: relays.slice(0, 2)
1124
- });
1125
- return `https://njump.me/${nevent}`;
1126
- }
1127
2153
 
1128
- // src/index.ts
1129
- var ElisymClient = class {
1130
- pool;
1131
- discovery;
1132
- marketplace;
1133
- messaging;
1134
- constructor(config = {}) {
1135
- this.pool = new NostrPool(config.relays ?? RELAYS);
1136
- this.discovery = new DiscoveryService(this.pool);
1137
- this.marketplace = new MarketplaceService(this.pool);
1138
- this.messaging = new MessagingService(this.pool);
1139
- }
1140
- close() {
1141
- 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.");
1142
2158
  }
1143
- };
2159
+ }
2160
+ function serializeConfig(config) {
2161
+ return JSON.stringify(config, null, 2) + "\n";
2162
+ }
1144
2163
 
1145
- 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 };
1146
2165
  //# sourceMappingURL=index.js.map
1147
2166
  //# sourceMappingURL=index.js.map