@elisym/sdk 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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,17 +522,45 @@ 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);
267
558
  if (existing) {
268
559
  const dupIndex = existing.entries.findIndex((e) => e.card.name === card.name);
269
560
  if (dupIndex >= 0) {
270
- existing.entries[dupIndex] = entry;
561
+ if (entry.createdAt >= existing.entries[dupIndex].createdAt) {
562
+ existing.entries[dupIndex] = entry;
563
+ }
271
564
  } else {
272
565
  existing.entries.push(entry);
273
566
  }
@@ -289,12 +582,13 @@ function buildAgentsFromEvents(events, network) {
289
582
  }
290
583
  const agentMap = /* @__PURE__ */ new Map();
291
584
  for (const [pubkey, acc] of accumMap) {
292
- const supportedKinds = [];
585
+ const kindsSet = /* @__PURE__ */ new Set();
293
586
  for (const e of acc.entries) {
294
587
  for (const k of e.kTags) {
295
- if (!supportedKinds.includes(k)) supportedKinds.push(k);
588
+ kindsSet.add(k);
296
589
  }
297
590
  }
591
+ const supportedKinds = [...kindsSet];
298
592
  agentMap.set(pubkey, {
299
593
  pubkey: acc.pubkey,
300
594
  npub: acc.npub,
@@ -310,23 +604,28 @@ var DiscoveryService = class {
310
604
  constructor(pool) {
311
605
  this.pool = pool;
312
606
  }
313
- // Instance-level set — avoids module-level state leak across clients
314
- allSeenAgents = /* @__PURE__ */ new Set();
315
607
  /** Count elisym agents (kind:31990 with "elisym" tag). */
316
608
  async fetchAllAgentCount() {
317
609
  const events = await this.pool.querySync({
318
610
  kinds: [KIND_APP_HANDLER],
319
611
  "#t": ["elisym"]
320
612
  });
613
+ const uniquePubkeys = /* @__PURE__ */ new Set();
321
614
  for (const event of events) {
322
- this.allSeenAgents.add(event.pubkey);
615
+ if (!nostrTools.verifyEvent(event)) {
616
+ continue;
617
+ }
618
+ uniquePubkeys.add(event.pubkey);
323
619
  }
324
- return this.allSeenAgents.size;
620
+ return uniquePubkeys.size;
325
621
  }
326
622
  /**
327
623
  * Fetch a single page of elisym agents with relay-side pagination.
328
624
  * Uses `until` cursor for Nostr cursor-based pagination.
329
- * 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.
330
629
  */
331
630
  async fetchAgentsPage(network = "devnet", limit = 20, until) {
332
631
  const filter = {
@@ -346,21 +645,24 @@ var DiscoveryService = class {
346
645
  }
347
646
  }
348
647
  const agentMap = buildAgentsFromEvents(events, network);
349
- const agents = Array.from(agentMap.values()).sort(
350
- (a, b) => b.lastSeen - a.lastSeen
351
- );
648
+ const agents = Array.from(agentMap.values()).sort((a, b) => b.lastSeen - a.lastSeen);
352
649
  return { agents, oldestCreatedAt, rawEventCount };
353
650
  }
354
651
  /** Enrich agents with kind:0 metadata (name, picture, about). Mutates in place and returns the same array. */
355
652
  async enrichWithMetadata(agents) {
356
653
  const pubkeys = agents.map((a) => a.pubkey);
357
- if (pubkeys.length === 0) return agents;
654
+ if (pubkeys.length === 0) {
655
+ return agents;
656
+ }
358
657
  const metaEvents = await this.pool.queryBatched(
359
658
  { kinds: [0] },
360
659
  pubkeys
361
660
  );
362
661
  const latestMeta = /* @__PURE__ */ new Map();
363
662
  for (const ev of metaEvents) {
663
+ if (!nostrTools.verifyEvent(ev)) {
664
+ continue;
665
+ }
364
666
  const prev = latestMeta.get(ev.pubkey);
365
667
  if (!prev || ev.created_at > prev.created_at) {
366
668
  latestMeta.set(ev.pubkey, ev);
@@ -369,14 +671,22 @@ var DiscoveryService = class {
369
671
  const agentLookup = new Map(agents.map((a) => [a.pubkey, a]));
370
672
  for (const [pubkey, ev] of latestMeta) {
371
673
  const agent = agentLookup.get(pubkey);
372
- if (!agent) continue;
674
+ if (!agent) {
675
+ continue;
676
+ }
373
677
  try {
374
678
  const meta = JSON.parse(ev.content);
375
- if (meta.picture) agent.picture = meta.picture;
376
- if (meta.name) agent.name = meta.name;
377
- if (meta.about) agent.about = meta.about;
378
- } catch {
379
- }
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
+ }
688
+ } catch {
689
+ }
380
690
  }
381
691
  return agents;
382
692
  }
@@ -386,9 +696,12 @@ var DiscoveryService = class {
386
696
  kinds: [KIND_APP_HANDLER],
387
697
  "#t": ["elisym"]
388
698
  };
389
- if (limit !== void 0) filter.limit = limit;
699
+ if (limit !== void 0) {
700
+ filter.limit = limit;
701
+ }
390
702
  const events = await this.pool.querySync(filter);
391
703
  const agentMap = buildAgentsFromEvents(events, network);
704
+ const agents = Array.from(agentMap.values()).sort((a, b) => b.lastSeen - a.lastSeen);
392
705
  const agentPubkeys = Array.from(agentMap.keys());
393
706
  if (agentPubkeys.length > 0) {
394
707
  const activitySince = Math.floor(Date.now() / 1e3) - 24 * 60 * 60;
@@ -401,33 +714,65 @@ var DiscoveryService = class {
401
714
  }
402
715
  }
403
716
  resultKinds.add(jobResultKind(DEFAULT_KIND_OFFSET));
404
- const activityEvents = await this.pool.queryBatched(
405
- {
406
- kinds: [...resultKinds, KIND_JOB_FEEDBACK],
407
- since: activitySince
408
- },
409
- agentPubkeys
410
- );
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
+ ]);
411
727
  for (const ev of activityEvents) {
728
+ if (!nostrTools.verifyEvent(ev)) {
729
+ continue;
730
+ }
412
731
  const agent = agentMap.get(ev.pubkey);
413
732
  if (agent && ev.created_at > agent.lastSeen) {
414
733
  agent.lastSeen = ev.created_at;
415
734
  }
416
735
  }
736
+ agents.sort((a, b) => b.lastSeen - a.lastSeen);
417
737
  }
418
- const agents = Array.from(agentMap.values()).sort(
419
- (a, b) => b.lastSeen - a.lastSeen
420
- );
421
- await this.enrichWithMetadata(agents);
422
738
  return agents;
423
739
  }
424
- /** 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
+ */
425
745
  async publishCapability(identity, card, kinds = [KIND_JOB_REQUEST]) {
426
746
  if (!card.payment?.address) {
427
747
  throw new Error(
428
748
  "Cannot publish capability without a payment address. Connect a wallet before publishing."
429
749
  );
430
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
+ }
431
776
  const tags = [
432
777
  ["d", toDTag(card.name)],
433
778
  ["t", "elisym"],
@@ -443,13 +788,28 @@ var DiscoveryService = class {
443
788
  },
444
789
  identity.secretKey
445
790
  );
446
- await this.pool.publish(event);
791
+ await this.pool.publishAll(event);
447
792
  return event.id;
448
793
  }
449
794
  /** Publish a Nostr profile (kind:0) as a provider. */
450
- 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
+ }
451
806
  const content = { name, about };
452
- if (picture) content.picture = picture;
807
+ if (picture) {
808
+ content.picture = picture;
809
+ }
810
+ if (banner) {
811
+ content.banner = banner;
812
+ }
453
813
  const event = nostrTools.finalizeEvent(
454
814
  {
455
815
  kind: 0,
@@ -459,7 +819,7 @@ var DiscoveryService = class {
459
819
  },
460
820
  identity.secretKey
461
821
  );
462
- await this.pool.publish(event);
822
+ await this.pool.publishAll(event);
463
823
  return event.id;
464
824
  }
465
825
  /**
@@ -486,20 +846,40 @@ var DiscoveryService = class {
486
846
  return event.id;
487
847
  }
488
848
  };
489
- function isEncrypted(event) {
490
- return event.tags.some((t) => t[0] === "encrypted" && t[1] === "nip44");
491
- }
492
- function nip44Encrypt(plaintext, secretKey, recipientPubkey) {
493
- 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);
494
851
  return nip44__namespace.v2.encrypt(plaintext, conversationKey);
495
852
  }
496
- function nip44Decrypt(ciphertext, secretKey, senderPubkey) {
497
- 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);
498
855
  return nip44__namespace.v2.decrypt(ciphertext, conversationKey);
499
856
  }
857
+
858
+ // src/services/marketplace.ts
859
+ function isEncrypted(event) {
860
+ return event.tags.some((t) => t[0] === "encrypted" && t[1] === "nip44");
861
+ }
500
862
  function resolveRequestId(event) {
501
863
  return event.tags.find((t) => t[0] === "e")?.[1];
502
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
+ }
503
883
  var MarketplaceService = class {
504
884
  constructor(pool) {
505
885
  this.pool = pool;
@@ -509,11 +889,21 @@ var MarketplaceService = class {
509
889
  if (!options.input) {
510
890
  throw new Error("Job input must not be empty.");
511
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
+ }
512
903
  const plaintext = options.input;
513
904
  const encrypted = options.providerPubkey ? nip44Encrypt(plaintext, identity.secretKey, options.providerPubkey) : plaintext;
514
- const iValue = options.providerPubkey ? "encrypted" : "";
515
905
  const tags = [
516
- ["i", iValue, "text"],
906
+ ["i", options.providerPubkey ? "encrypted" : "text", "text"],
517
907
  ["t", options.capability],
518
908
  ["t", "elisym"],
519
909
  ["output", "text/plain"]
@@ -537,80 +927,148 @@ var MarketplaceService = class {
537
927
  }
538
928
  /**
539
929
  * Subscribe to job updates (feedback + results) for a given job.
540
- * 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.
541
932
  */
542
- subscribeToJobUpdates(jobEventId, providerPubkey, customerPublicKey, callbacks, timeoutMs = 12e4, customerSecretKey, kindOffsets) {
543
- 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
+ }
544
948
  const resultKinds = offsets.map(jobResultKind);
545
- const since = Math.floor(Date.now() / 1e3) - 5;
949
+ const since = since_ ?? Math.floor(Date.now() / 1e3) - 30;
546
950
  const subs = [];
547
951
  let resolved = false;
548
952
  let resultDelivered = false;
549
953
  let timer;
550
954
  const done = () => {
551
955
  resolved = true;
552
- if (timer) clearTimeout(timer);
553
- 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
+ }
554
965
  };
555
966
  const decryptResult = (ev) => {
556
- if (customerSecretKey && isEncrypted(ev)) {
967
+ if (isEncrypted(ev)) {
968
+ if (!custSk) {
969
+ return null;
970
+ }
557
971
  try {
558
- return nip44Decrypt(ev.content, customerSecretKey, ev.pubkey);
972
+ return nip44Decrypt(ev.content, custSk, ev.pubkey);
559
973
  } catch {
560
- return ev.content;
974
+ return null;
561
975
  }
562
976
  }
563
977
  return ev.content;
564
978
  };
565
979
  const handleResult = (ev) => {
566
- if (resolved || resultDelivered) return;
567
- 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
+ }
568
997
  resultDelivered = true;
569
- callbacks.onResult?.(decryptResult(ev), ev.id);
570
- done();
571
- };
572
- const feedbackSub = this.pool.subscribe(
573
- {
574
- kinds: [KIND_JOB_FEEDBACK],
575
- "#e": [jobEventId],
576
- since
577
- },
578
- (ev) => {
579
- if (resolved) return;
580
- if (providerPubkey && ev.pubkey !== providerPubkey) return;
581
- const statusTag = ev.tags.find((t) => t[0] === "status");
582
- if (statusTag?.[1] === "payment-required") {
583
- const amtTag = ev.tags.find((t) => t[0] === "amount");
584
- const amt = amtTag?.[1] ? parseInt(amtTag[1], 10) : 0;
585
- const paymentReq = amtTag?.[2];
586
- callbacks.onFeedback?.("payment-required", amt, paymentReq);
587
- }
998
+ try {
999
+ cb.onResult?.(content, ev.id);
1000
+ } catch {
1001
+ } finally {
1002
+ done();
588
1003
  }
589
- );
590
- subs.push(feedbackSub);
591
- const resultSub = this.pool.subscribe(
592
- {
593
- kinds: resultKinds,
594
- "#e": [jobEventId],
595
- since
596
- },
597
- handleResult
598
- );
599
- subs.push(resultSub);
600
- const resultSub2 = this.pool.subscribe(
601
- {
602
- kinds: resultKinds,
603
- "#p": [customerPublicKey],
604
- "#e": [jobEventId],
605
- since
606
- },
607
- handleResult
608
- );
609
- 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
+ }
610
1065
  timer = setTimeout(() => {
611
1066
  if (!resolved) {
612
1067
  done();
613
- 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
+ }
614
1072
  }
615
1073
  }, timeoutMs);
616
1074
  return done;
@@ -632,7 +1090,7 @@ var MarketplaceService = class {
632
1090
  },
633
1091
  identity.secretKey
634
1092
  );
635
- await this.pool.publish(event);
1093
+ await this.pool.publishAll(event);
636
1094
  }
637
1095
  /** Submit rating feedback for a job. */
638
1096
  async submitFeedback(identity, jobEventId, providerPubkey, positive, capability) {
@@ -643,7 +1101,9 @@ var MarketplaceService = class {
643
1101
  ["rating", positive ? "1" : "0"],
644
1102
  ["t", "elisym"]
645
1103
  ];
646
- if (capability) tags.push(["t", capability]);
1104
+ if (capability) {
1105
+ tags.push(["t", capability]);
1106
+ }
647
1107
  const event = nostrTools.finalizeEvent(
648
1108
  {
649
1109
  kind: KIND_JOB_FEEDBACK,
@@ -653,31 +1113,67 @@ var MarketplaceService = class {
653
1113
  },
654
1114
  identity.secretKey
655
1115
  );
656
- await this.pool.publish(event);
1116
+ await this.pool.publishAll(event);
657
1117
  }
658
1118
  // --- Provider methods ---
659
- /** 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
+ */
660
1125
  subscribeToJobRequests(identity, kinds, onRequest) {
661
1126
  return this.pool.subscribe(
662
1127
  {
663
1128
  kinds,
664
1129
  "#p": [identity.publicKey],
665
- since: Math.floor(Date.now() / 1e3)
1130
+ "#t": ["elisym"],
1131
+ since: Math.floor(Date.now() / 1e3) - 5
666
1132
  },
667
- 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
+ }
668
1148
  );
669
1149
  }
670
1150
  /** Submit a job result with NIP-44 encrypted content. Result kind is derived from the request kind. */
671
1151
  async submitJobResult(identity, requestEvent, content, amount) {
672
- const encrypted = nip44Encrypt(content, identity.secretKey, requestEvent.pubkey);
673
- 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;
674
1167
  const tags = [
675
1168
  ["e", requestEvent.id],
676
1169
  ["p", requestEvent.pubkey],
677
- ["t", "elisym"],
678
- ["encrypted", "nip44"]
1170
+ ["t", "elisym"]
679
1171
  ];
680
- if (amount != null) {
1172
+ if (shouldEncrypt) {
1173
+ tags.push(["encrypted", "nip44"]);
1174
+ }
1175
+ if (amount !== null && amount !== void 0) {
1176
+ assertLamports(amount, "result amount");
681
1177
  tags.push(["amount", String(amount)]);
682
1178
  }
683
1179
  const event = nostrTools.finalizeEvent(
@@ -685,15 +1181,50 @@ var MarketplaceService = class {
685
1181
  kind: resultKind,
686
1182
  created_at: Math.floor(Date.now() / 1e3),
687
1183
  tags,
688
- content: encrypted
1184
+ content: resultContent
689
1185
  },
690
1186
  identity.secretKey
691
1187
  );
692
- await this.pool.publish(event);
1188
+ await this.pool.publishAll(event);
693
1189
  return event.id;
694
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
+ }
695
1212
  /** Submit payment-required feedback with a payment request. */
696
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
+ }
697
1228
  const event = nostrTools.finalizeEvent(
698
1229
  {
699
1230
  kind: KIND_JOB_FEEDBACK,
@@ -709,74 +1240,193 @@ var MarketplaceService = class {
709
1240
  },
710
1241
  identity.secretKey
711
1242
  );
712
- 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;
713
1338
  }
714
1339
  // --- Query methods ---
715
- /** 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
+ */
716
1345
  async fetchRecentJobs(agentPubkeys, limit, since, kindOffsets) {
717
1346
  const offsets = kindOffsets ?? [DEFAULT_KIND_OFFSET];
1347
+ if (offsets.length === 0) {
1348
+ throw new Error("kindOffsets must not be empty.");
1349
+ }
718
1350
  const requestKinds = offsets.map(jobRequestKind);
719
1351
  const resultKinds = offsets.map(jobResultKind);
720
1352
  const reqFilter = {
721
1353
  kinds: requestKinds,
722
1354
  "#t": ["elisym"],
723
- ...limit != null && { limit },
724
- ...since != null && { since }
1355
+ ...limit !== null && limit !== void 0 && { limit },
1356
+ ...since !== null && since !== void 0 && { since }
725
1357
  };
726
- const requests = await this.pool.querySync(reqFilter);
1358
+ const rawRequests = await this.pool.querySync(reqFilter);
1359
+ const requests = rawRequests.filter(nostrTools.verifyEvent);
727
1360
  const requestIds = requests.map((r) => r.id);
728
1361
  let results = [];
729
1362
  let feedbacks = [];
730
1363
  if (requestIds.length > 0) {
731
- const [resultArrays, feedbackArrays] = await Promise.all([
732
- this.pool.queryBatchedByTag(
733
- { kinds: resultKinds },
734
- "e",
735
- requestIds
736
- ),
737
- this.pool.queryBatchedByTag(
738
- { kinds: [KIND_JOB_FEEDBACK] },
739
- "e",
740
- requestIds
741
- )
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)
742
1367
  ]);
743
- results = resultArrays;
744
- feedbacks = feedbackArrays;
1368
+ results = rawResults.filter(nostrTools.verifyEvent);
1369
+ feedbacks = rawFeedbacks.filter(nostrTools.verifyEvent);
745
1370
  }
746
1371
  const targetedAgentByRequest = /* @__PURE__ */ new Map();
747
1372
  for (const req of requests) {
748
1373
  const pTag = req.tags.find((t) => t[0] === "p");
749
- if (pTag?.[1]) targetedAgentByRequest.set(req.id, pTag[1]);
1374
+ if (pTag?.[1]) {
1375
+ targetedAgentByRequest.set(req.id, pTag[1]);
1376
+ }
750
1377
  }
751
1378
  const resultsByRequest = /* @__PURE__ */ new Map();
752
1379
  for (const r of results) {
753
1380
  const reqId = resolveRequestId(r);
754
- if (!reqId) continue;
1381
+ if (!reqId) {
1382
+ continue;
1383
+ }
755
1384
  const targeted = targetedAgentByRequest.get(reqId);
756
- if (targeted && r.pubkey !== targeted) continue;
1385
+ if (targeted && r.pubkey !== targeted) {
1386
+ continue;
1387
+ }
757
1388
  const existing = resultsByRequest.get(reqId);
758
- if (!existing || targeted && r.pubkey === targeted) {
1389
+ if (!existing || r.created_at > existing.created_at) {
759
1390
  resultsByRequest.set(reqId, r);
760
1391
  }
761
1392
  }
762
1393
  const feedbackByRequest = /* @__PURE__ */ new Map();
763
1394
  for (const f of feedbacks) {
764
1395
  const reqId = resolveRequestId(f);
765
- if (!reqId) continue;
1396
+ if (!reqId) {
1397
+ continue;
1398
+ }
766
1399
  const targeted = targetedAgentByRequest.get(reqId);
767
- if (targeted && f.pubkey !== targeted) continue;
1400
+ if (targeted && f.pubkey !== targeted) {
1401
+ continue;
1402
+ }
768
1403
  const existing = feedbackByRequest.get(reqId);
769
- if (!existing || targeted && f.pubkey === targeted) {
1404
+ if (!existing || f.created_at > existing.created_at) {
770
1405
  feedbackByRequest.set(reqId, f);
771
1406
  }
772
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
+ }
773
1421
  const jobs = [];
774
1422
  for (const req of requests) {
775
1423
  const result = resultsByRequest.get(req.id);
776
1424
  const feedback = feedbackByRequest.get(req.id);
777
1425
  const jobAgentPubkey = result?.pubkey ?? feedback?.pubkey;
778
1426
  if (agentPubkeys && agentPubkeys.size > 0 && jobAgentPubkey) {
779
- if (!agentPubkeys.has(jobAgentPubkey)) continue;
1427
+ if (!agentPubkeys.has(jobAgentPubkey)) {
1428
+ continue;
1429
+ }
780
1430
  }
781
1431
  const capability = req.tags.find((t) => t[0] === "t" && t[1] !== "elisym")?.[1];
782
1432
  const bid = req.tags.find((t) => t[0] === "bid")?.[1];
@@ -786,11 +1436,9 @@ var MarketplaceService = class {
786
1436
  if (result) {
787
1437
  status = "success";
788
1438
  const amtTag = result.tags.find((t) => t[0] === "amount");
789
- if (amtTag?.[1]) amount = parseInt(amtTag[1], 10);
1439
+ amount = safeParseInt(amtTag?.[1]);
790
1440
  }
791
- const allFeedbacksForReq = feedbacks.filter(
792
- (f) => resolveRequestId(f) === req.id
793
- );
1441
+ const allFeedbacksForReq = feedbacksByRequestId.get(req.id) ?? [];
794
1442
  for (const fb of allFeedbacksForReq) {
795
1443
  const txTag = fb.tags.find((t) => t[0] === "tx");
796
1444
  if (txTag?.[1]) {
@@ -804,13 +1452,13 @@ var MarketplaceService = class {
804
1452
  if (statusTag?.[1]) {
805
1453
  const isTargeted = targetedAgentByRequest.has(req.id);
806
1454
  if (statusTag[1] === "payment-required" && !bid && !isTargeted) ; else {
807
- status = statusTag[1];
1455
+ status = toJobStatus(statusTag[1]);
808
1456
  }
809
1457
  }
810
1458
  }
811
1459
  if (!amount) {
812
1460
  const amtTag = feedback.tags.find((t) => t[0] === "amount");
813
- if (amtTag?.[1]) amount = parseInt(amtTag[1], 10);
1461
+ amount = safeParseInt(amtTag?.[1]);
814
1462
  }
815
1463
  }
816
1464
  jobs.push({
@@ -818,7 +1466,7 @@ var MarketplaceService = class {
818
1466
  customer: req.pubkey,
819
1467
  agentPubkey: jobAgentPubkey,
820
1468
  capability,
821
- bid: bid ? parseInt(bid, 10) : void 0,
1469
+ bid: safeParseInt(bid),
822
1470
  status,
823
1471
  result: result?.content,
824
1472
  resultEventId: result?.id,
@@ -837,96 +1485,285 @@ var MarketplaceService = class {
837
1485
  "#t": ["elisym"],
838
1486
  since: Math.floor(Date.now() / 1e3)
839
1487
  },
840
- onEvent
1488
+ (event) => {
1489
+ if (!nostrTools.verifyEvent(event)) {
1490
+ return;
1491
+ }
1492
+ onEvent(event);
1493
+ }
841
1494
  );
842
1495
  }
843
1496
  };
844
- var MessagingService = class _MessagingService {
845
- // 30s
846
- constructor(pool) {
847
- this.pool = pool;
848
- 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;
849
1502
  }
850
- sessionIdentity;
851
- pingCache = /* @__PURE__ */ new Map();
852
- // pubkey → timestamp of last online result
853
- pendingPings = /* @__PURE__ */ new Map();
854
- // dedup in-flight pings
855
- static PING_CACHE_TTL = 3e4;
856
1503
  /**
857
- * Ping an agent via ephemeral Nostr events (kind 20200/20201).
858
- * Uses a persistent session identity to avoid relay rate-limiting.
859
- * Publishes to ALL relays for maximum delivery reliability.
860
- * 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.
861
1511
  */
862
- async pingAgent(agentPubkey, timeoutMs = 15e3, signal) {
863
- const cachedAt = this.pingCache.get(agentPubkey);
864
- if (cachedAt && Date.now() - cachedAt < _MessagingService.PING_CACHE_TTL) {
865
- console.log(`[ping] cache hit for ${agentPubkey.slice(0, 8)}: online`);
866
- return { online: true, identity: this.sessionIdentity };
867
- }
868
- const pending = this.pendingPings.get(agentPubkey);
869
- if (pending) {
870
- console.log(`[ping] dedup: reusing in-flight ping for ${agentPubkey.slice(0, 8)}`);
871
- return pending;
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);
872
1556
  }
873
- const promise = this._doPing(agentPubkey, timeoutMs, signal);
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.");
1566
+ }
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);
874
1673
  this.pendingPings.set(agentPubkey, promise);
875
1674
  promise.finally(() => this.pendingPings.delete(agentPubkey));
876
1675
  return promise;
877
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 };
1690
+ }
878
1691
  async _doPing(agentPubkey, timeoutMs, signal) {
879
1692
  const sk = this.sessionIdentity.secretKey;
880
1693
  const pk = this.sessionIdentity.publicKey;
881
1694
  const nonce = crypto.getRandomValues(new Uint8Array(16)).reduce((s, b) => s + b.toString(16).padStart(2, "0"), "");
882
- const shortNonce = nonce.slice(0, 8);
883
- const shortAgent = agentPubkey.slice(0, 8);
884
- console.log(`[ping] \u2192 ping ${shortAgent} nonce=${shortNonce}`);
885
1695
  if (signal?.aborted) {
886
1696
  return { online: false, identity: null };
887
1697
  }
888
- return new Promise(async (resolve) => {
889
- let resolved = false;
890
- const done = (online, reason) => {
891
- if (resolved) return;
892
- 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) {
893
1711
  clearTimeout(timer);
894
- sub.close();
895
- signal?.removeEventListener("abort", onAbort);
896
- console.log(`[ping] ${online ? "\u2713 ONLINE" : "\u2717 OFFLINE"} agent=${shortAgent} nonce=${shortNonce}${reason ? ` (${reason})` : ""}`);
897
- if (online) this.pingCache.set(agentPubkey, Date.now());
898
- resolve({ online, identity: online ? this.sessionIdentity : null });
899
- };
900
- const onAbort = () => done(false, "aborted");
901
- signal?.addEventListener("abort", onAbort);
902
- const sub = this.pool.subscribe(
903
- { kinds: [KIND_PONG], "#p": [pk] },
904
- (ev) => {
905
- try {
906
- const msg = JSON.parse(ev.content);
907
- if (msg.type === "elisym_pong" && msg.nonce === nonce) {
908
- console.log(`[ping] \u2190 pong from ${ev.pubkey.slice(0, 8)} nonce=${shortNonce}`);
909
- done(true, "pong matched");
910
- }
911
- } 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);
912
1722
  }
913
1723
  }
914
- );
915
- const pingEvent = nostrTools.finalizeEvent(
916
- {
917
- kind: KIND_PING,
918
- created_at: Math.floor(Date.now() / 1e3),
919
- tags: [["p", agentPubkey]],
920
- content: JSON.stringify({ type: "elisym_ping", nonce })
921
- },
922
- sk
923
- );
924
- this.pool.publishAll(pingEvent).then(() => console.log(`[ping] \u2713 published nonce=${shortNonce}`)).catch((err) => {
925
- console.error(`[ping] \u2717 publish failed nonce=${shortNonce}`, err);
926
- 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
+ }
927
1745
  });
928
- 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);
929
1765
  });
1766
+ return promise;
930
1767
  }
931
1768
  /**
932
1769
  * Subscribe to incoming ephemeral ping events (kind 20200).
@@ -936,9 +1773,12 @@ var MessagingService = class _MessagingService {
936
1773
  return this.pool.subscribe(
937
1774
  { kinds: [KIND_PING], "#p": [identity.publicKey] },
938
1775
  (ev) => {
1776
+ if (!nostrTools.verifyEvent(ev)) {
1777
+ return;
1778
+ }
939
1779
  try {
940
1780
  const msg = JSON.parse(ev.content);
941
- if (msg.type === "elisym_ping" && msg.nonce) {
1781
+ if (msg.type === "elisym_ping" && typeof msg.nonce === "string" && msg.nonce.length === 32) {
942
1782
  onPing(ev.pubkey, msg.nonce);
943
1783
  }
944
1784
  } catch {
@@ -961,11 +1801,15 @@ var MessagingService = class _MessagingService {
961
1801
  }
962
1802
  /** Send a NIP-17 DM. */
963
1803
  async sendMessage(identity, recipientPubkey, content) {
964
- const wrap = nip17__namespace.wrapEvent(
965
- identity.secretKey,
966
- { publicKey: recipientPubkey },
967
- content
968
- );
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);
969
1813
  await this.pool.publish(wrap);
970
1814
  }
971
1815
  /** Fetch historical NIP-17 DMs from relays. Returns decrypted messages sorted by time. */
@@ -975,12 +1819,17 @@ var MessagingService = class _MessagingService {
975
1819
  "#p": [identity.publicKey],
976
1820
  since
977
1821
  });
978
- const seen = /* @__PURE__ */ new Set();
1822
+ const seen = new BoundedSet(1e4);
979
1823
  const messages = [];
980
1824
  for (const ev of events) {
981
1825
  try {
982
1826
  const rumor = nip59__namespace.unwrapEvent(ev, identity.secretKey);
983
- if (seen.has(rumor.id)) continue;
1827
+ if (rumor.kind !== 14) {
1828
+ continue;
1829
+ }
1830
+ if (seen.has(rumor.id)) {
1831
+ continue;
1832
+ }
984
1833
  seen.add(rumor.id);
985
1834
  messages.push({
986
1835
  senderPubkey: rumor.pubkey,
@@ -995,134 +1844,312 @@ var MessagingService = class _MessagingService {
995
1844
  }
996
1845
  /** Subscribe to incoming NIP-17 DMs. */
997
1846
  subscribeToMessages(identity, onMessage, since) {
998
- const seen = /* @__PURE__ */ new Set();
1847
+ const seen = new BoundedSet(1e4);
999
1848
  const filter = {
1000
1849
  kinds: [KIND_GIFT_WRAP],
1001
1850
  "#p": [identity.publicKey]
1002
1851
  };
1003
- if (since !== void 0) filter.since = since;
1004
- return this.pool.subscribe(
1005
- filter,
1006
- (ev) => {
1007
- try {
1008
- const rumor = nip59__namespace.unwrapEvent(ev, identity.secretKey);
1009
- if (seen.has(rumor.id)) return;
1010
- seen.add(rumor.id);
1011
- onMessage(rumor.pubkey, rumor.content, rumor.created_at, rumor.id);
1012
- } 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;
1013
1863
  }
1864
+ seen.add(rumor.id);
1865
+ onMessage(rumor.pubkey, rumor.content, rumor.created_at, rumor.id);
1866
+ } catch {
1014
1867
  }
1015
- );
1868
+ });
1016
1869
  }
1017
1870
  };
1018
- var PaymentService = class _PaymentService {
1019
- /**
1020
- * Calculate protocol fee using Decimal basis-point math (no floats).
1021
- * Returns ceil(amount * PROTOCOL_FEE_BPS / 10000).
1022
- */
1023
- static calculateProtocolFee(amount) {
1024
- if (amount === 0) return 0;
1025
- 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;
1026
1878
  }
1027
- /**
1028
- * Validate that a payment request has the correct recipient and protocol fee.
1029
- * Returns an error message if invalid, null if OK.
1030
- */
1031
- static validatePaymentFee(requestJson, expectedRecipient) {
1032
- 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
+ });
1033
1885
  try {
1034
- data = JSON.parse(requestJson);
1035
- } catch (e) {
1036
- 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);
1037
1895
  }
1038
- if (expectedRecipient && data.recipient !== expectedRecipient) {
1039
- 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());
1040
1929
  }
1041
- if (data.created_at > 0 && data.expiry_secs > 0) {
1042
- const elapsed = Math.floor(Date.now() / 1e3) - data.created_at;
1043
- if (elapsed > data.expiry_secs) {
1044
- 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
+ );
1045
1975
  }
1976
+ throw err;
1046
1977
  }
1047
- const expectedFee = _PaymentService.calculateProtocolFee(data.amount);
1048
- const { fee_address, fee_amount } = data;
1049
- if (fee_address && fee_amount && fee_amount > 0) {
1050
- if (fee_address !== PROTOCOL_TREASURY) {
1051
- 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);
1052
1993
  }
1053
- if (fee_amount !== expectedFee) {
1054
- 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
+ }
1055
2049
  }
1056
- 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");
1057
2067
  }
1058
- if (!fee_address && !fee_amount) {
1059
- 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 {
1060
2072
  }
1061
- return `Invalid fee params in payment request. Expected fee: ${expectedFee} lamports to ${PROTOCOL_TREASURY}.`;
2073
+ this.pool = new nostrTools.SimplePool();
1062
2074
  }
1063
2075
  /**
1064
- * Build a Solana transaction from a payment request.
1065
- * The caller must sign and send via wallet adapter.
2076
+ * Lightweight connectivity probe. Returns true if at least one relay responds.
1066
2077
  */
1067
- static buildPaymentTransaction(payerPubkey, paymentRequest) {
1068
- const recipient = new web3_js.PublicKey(paymentRequest.recipient);
1069
- const reference = new web3_js.PublicKey(paymentRequest.reference);
1070
- const feeAddress = paymentRequest.fee_address ? new web3_js.PublicKey(paymentRequest.fee_address) : null;
1071
- const feeAmount = paymentRequest.fee_amount ?? 0;
1072
- const providerAmount = feeAddress && feeAmount > 0 ? new Decimal__default.default(paymentRequest.amount).minus(feeAmount).toNumber() : paymentRequest.amount;
1073
- const transferIx = web3_js.SystemProgram.transfer({
1074
- fromPubkey: payerPubkey,
1075
- toPubkey: recipient,
1076
- lamports: providerAmount
1077
- });
1078
- transferIx.keys.push({
1079
- pubkey: reference,
1080
- isSigner: false,
1081
- 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(() => {
1082
2082
  });
1083
- const tx = new web3_js.Transaction().add(transferIx);
1084
- if (feeAddress && feeAmount > 0) {
1085
- tx.add(
1086
- web3_js.SystemProgram.transfer({
1087
- fromPubkey: payerPubkey,
1088
- toPubkey: feeAddress,
1089
- lamports: feeAmount
2083
+ try {
2084
+ await Promise.race([
2085
+ query,
2086
+ new Promise((_, reject) => {
2087
+ timer = setTimeout(() => reject(new Error("probe timeout")), timeoutMs);
1090
2088
  })
1091
- );
2089
+ ]);
2090
+ return true;
2091
+ } catch {
2092
+ return false;
2093
+ } finally {
2094
+ clearTimeout(timer);
1092
2095
  }
1093
- return tx;
1094
2096
  }
1095
- /**
1096
- * Create a payment request with auto-calculated protocol fee.
1097
- * Used by providers to generate payment requests for customers.
1098
- */
1099
- static createPaymentRequest(recipientAddress, amount, expirySecs = 600) {
1100
- const feeAmount = _PaymentService.calculateProtocolFee(amount);
1101
- const reference = web3_js.PublicKey.unique().toBase58();
1102
- return {
1103
- recipient: recipientAddress,
1104
- amount,
1105
- reference,
1106
- fee_address: PROTOCOL_TREASURY,
1107
- fee_amount: feeAmount,
1108
- created_at: Math.floor(Date.now() / 1e3),
1109
- expiry_secs: expirySecs
1110
- };
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();
1111
2130
  }
1112
2131
  };
1113
2132
  function formatSol(lamports) {
1114
- const sol = new Decimal__default.default(lamports).div(LAMPORTS_PER_SOL);
1115
- if (sol.gte(1e6)) return `${sol.idiv(1e6)}m SOL`;
1116
- 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
+ }
1117
2140
  return `${compactSol(sol)} SOL`;
1118
2141
  }
1119
2142
  function compactSol(sol) {
1120
- if (sol.isZero()) return "0";
1121
- 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
+ }
1122
2149
  const maxFrac = 9;
1123
2150
  for (let d = 1; d <= maxFrac; d++) {
1124
2151
  const s = sol.toFixed(d);
1125
- if (new Decimal__default.default(s).eq(sol)) {
2152
+ if (new Decimal2__default.default(s).eq(sol)) {
1126
2153
  return s.replace(/0+$/, "").replace(/\.$/, "");
1127
2154
  }
1128
2155
  }
@@ -1130,43 +2157,39 @@ function compactSol(sol) {
1130
2157
  }
1131
2158
  function timeAgo(unix) {
1132
2159
  const seconds = Math.max(0, Math.floor(Date.now() / 1e3 - unix));
1133
- if (seconds < 60) return `${seconds}s ago`;
2160
+ if (seconds < 60) {
2161
+ return `${seconds}s ago`;
2162
+ }
1134
2163
  const minutes = Math.floor(seconds / 60);
1135
- if (minutes < 60) return `${minutes}m ago`;
2164
+ if (minutes < 60) {
2165
+ return `${minutes}m ago`;
2166
+ }
1136
2167
  const hours = Math.floor(minutes / 60);
1137
- if (hours < 24) return `${hours}h ago`;
2168
+ if (hours < 24) {
2169
+ return `${hours}h ago`;
2170
+ }
1138
2171
  const days = Math.floor(hours / 24);
1139
2172
  return `${days}d ago`;
1140
2173
  }
1141
2174
  function truncateKey(hex, chars = 6) {
1142
- if (hex.length <= chars * 2) return hex;
2175
+ if (hex.length <= chars * 2) {
2176
+ return hex;
2177
+ }
1143
2178
  return `${hex.slice(0, chars)}...${hex.slice(-chars)}`;
1144
2179
  }
1145
- function makeNjumpUrl(eventId, relays = RELAYS) {
1146
- const nevent = nostrTools.nip19.neventEncode({
1147
- id: eventId,
1148
- relays: relays.slice(0, 2)
1149
- });
1150
- return `https://njump.me/${nevent}`;
1151
- }
1152
2180
 
1153
- // src/index.ts
1154
- var ElisymClient = class {
1155
- pool;
1156
- discovery;
1157
- marketplace;
1158
- messaging;
1159
- constructor(config = {}) {
1160
- this.pool = new NostrPool(config.relays ?? RELAYS);
1161
- this.discovery = new DiscoveryService(this.pool);
1162
- this.marketplace = new MarketplaceService(this.pool);
1163
- this.messaging = new MessagingService(this.pool);
1164
- }
1165
- close() {
1166
- 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.");
1167
2185
  }
1168
- };
2186
+ }
2187
+ function serializeConfig(config) {
2188
+ return JSON.stringify(config, null, 2) + "\n";
2189
+ }
1169
2190
 
2191
+ exports.BoundedSet = BoundedSet;
2192
+ exports.DEFAULTS = DEFAULTS;
1170
2193
  exports.DEFAULT_KIND_OFFSET = DEFAULT_KIND_OFFSET;
1171
2194
  exports.DiscoveryService = DiscoveryService;
1172
2195
  exports.ElisymClient = ElisymClient;
@@ -1181,19 +2204,28 @@ exports.KIND_JOB_RESULT_BASE = KIND_JOB_RESULT_BASE;
1181
2204
  exports.KIND_PING = KIND_PING;
1182
2205
  exports.KIND_PONG = KIND_PONG;
1183
2206
  exports.LAMPORTS_PER_SOL = LAMPORTS_PER_SOL;
2207
+ exports.LIMITS = LIMITS;
1184
2208
  exports.MarketplaceService = MarketplaceService;
2209
+ exports.MediaService = MediaService;
1185
2210
  exports.MessagingService = MessagingService;
1186
2211
  exports.NostrPool = NostrPool;
1187
2212
  exports.PROTOCOL_FEE_BPS = PROTOCOL_FEE_BPS;
1188
2213
  exports.PROTOCOL_TREASURY = PROTOCOL_TREASURY;
1189
- exports.PaymentService = PaymentService;
1190
2214
  exports.RELAYS = RELAYS;
2215
+ exports.SolanaPaymentStrategy = SolanaPaymentStrategy;
2216
+ exports.assertExpiry = assertExpiry;
2217
+ exports.assertLamports = assertLamports;
2218
+ exports.calculateProtocolFee = calculateProtocolFee;
1191
2219
  exports.formatSol = formatSol;
1192
2220
  exports.jobRequestKind = jobRequestKind;
1193
2221
  exports.jobResultKind = jobResultKind;
1194
- exports.makeNjumpUrl = makeNjumpUrl;
2222
+ exports.nip44Decrypt = nip44Decrypt;
2223
+ exports.nip44Encrypt = nip44Encrypt;
2224
+ exports.serializeConfig = serializeConfig;
1195
2225
  exports.timeAgo = timeAgo;
1196
2226
  exports.toDTag = toDTag;
1197
2227
  exports.truncateKey = truncateKey;
2228
+ exports.validateAgentName = validateAgentName;
2229
+ exports.validateExpiry = validateExpiry;
1198
2230
  //# sourceMappingURL=index.cjs.map
1199
2231
  //# sourceMappingURL=index.cjs.map