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