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