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