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