lovewebconnection 0.0.0-stage → 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.
Files changed (68) hide show
  1. package/.env.example +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1915 -2
  4. package/dist/alert-queue.d.ts +72 -0
  5. package/dist/alert-queue.js +155 -0
  6. package/dist/alert-store.d.ts +29 -0
  7. package/dist/alert-store.js +75 -0
  8. package/dist/balances.d.ts +221 -0
  9. package/dist/balances.js +1232 -0
  10. package/dist/cache.d.ts +37 -0
  11. package/dist/cache.js +128 -0
  12. package/dist/call.d.ts +46 -0
  13. package/dist/call.js +377 -0
  14. package/dist/chain.d.ts +97 -0
  15. package/dist/chain.js +419 -0
  16. package/dist/cli.d.ts +2 -0
  17. package/dist/cli.js +1011 -0
  18. package/dist/compare-history.d.ts +82 -0
  19. package/dist/compare-history.js +340 -0
  20. package/dist/compare.d.ts +98 -0
  21. package/dist/compare.js +492 -0
  22. package/dist/contract.d.ts +107 -0
  23. package/dist/contract.js +162 -0
  24. package/dist/default-abi.d.ts +53 -0
  25. package/dist/default-abi.js +85 -0
  26. package/dist/history.d.ts +83 -0
  27. package/dist/history.js +422 -0
  28. package/dist/index.d.ts +54 -0
  29. package/dist/index.js +27 -0
  30. package/dist/market-page.d.ts +8 -0
  31. package/dist/market-page.js +554 -0
  32. package/dist/market-server.d.ts +46 -0
  33. package/dist/market-server.js +473 -0
  34. package/dist/market.d.ts +323 -0
  35. package/dist/market.js +835 -0
  36. package/dist/metadata-cache.d.ts +72 -0
  37. package/dist/metadata-cache.js +197 -0
  38. package/dist/metrics.d.ts +64 -0
  39. package/dist/metrics.js +138 -0
  40. package/dist/plot.d.ts +60 -0
  41. package/dist/plot.js +317 -0
  42. package/dist/presets-history.d.ts +372 -0
  43. package/dist/presets-history.js +1243 -0
  44. package/dist/presets-summary.d.ts +17 -0
  45. package/dist/presets-summary.js +55 -0
  46. package/dist/presets.d.ts +59 -0
  47. package/dist/presets.js +181 -0
  48. package/dist/prices.d.ts +63 -0
  49. package/dist/prices.js +154 -0
  50. package/dist/rotate.d.ts +19 -0
  51. package/dist/rotate.js +34 -0
  52. package/dist/scan.d.ts +81 -0
  53. package/dist/scan.js +501 -0
  54. package/dist/sessions.d.ts +104 -0
  55. package/dist/sessions.js +515 -0
  56. package/dist/signatures.d.ts +13 -0
  57. package/dist/signatures.js +43 -0
  58. package/dist/tokenfile.d.ts +50 -0
  59. package/dist/tokenfile.js +218 -0
  60. package/dist/views.d.ts +63 -0
  61. package/dist/views.js +415 -0
  62. package/dist/watch.d.ts +306 -0
  63. package/dist/watch.js +1121 -0
  64. package/dist/webhook.d.ts +17 -0
  65. package/dist/webhook.js +35 -0
  66. package/examples/erc20.abi.json +57 -0
  67. package/examples/xlayer.tokens.json +4 -0
  68. package/package.json +77 -4
@@ -0,0 +1,473 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { createServer } from "node:http";
4
+ import { MARKET_PAGE_HTML } from "./market-page.js";
5
+ import { DEFAULT_MARKET_RATE_LIMIT, MarketArgError, MarketError, MarketPool, MIN_MARKET_KEY_LENGTH, formatMarketAmount, parseMarketAmount, savePoolFile, toMarketJson, toSwapQuoteJson, } from "./market.js";
6
+ /**
7
+ * A small JSON API over one fixed-price pool, so a website can ask for a quote
8
+ * and submit a swap without touching the pool file itself. The server owns the
9
+ * file while it runs: every swap is written back before it is reported, and the
10
+ * in-memory pool is the source of truth until the process stops.
11
+ */
12
+ const MAX_BODY_BYTES = 64 * 1024;
13
+ /** Failed access-key attempts one client may make before it is blocked. */
14
+ const MAX_AUTH_FAILURES = 10;
15
+ /** Both the failure window and the block a client earns by filling it. */
16
+ const FAILURE_WINDOW_MS = 60_000;
17
+ /** The window the per-client request limit is measured over. */
18
+ const RATE_WINDOW_MS = 60_000;
19
+ /**
20
+ * Compare two secrets without leaking where they differ, or how long they are:
21
+ * both sides are hashed first, so the comparison is fixed-size.
22
+ */
23
+ function sameSecret(candidate, expected) {
24
+ const digest = (value) => createHash("sha256").update(value, "utf8").digest();
25
+ return timingSafeEqual(digest(candidate), digest(expected));
26
+ }
27
+ /** An error that maps straight to an HTTP status. */
28
+ export class MarketApiError extends Error {
29
+ status;
30
+ code;
31
+ constructor(status, message, code = null) {
32
+ super(message);
33
+ this.name = "MarketApiError";
34
+ this.status = status;
35
+ this.code = code;
36
+ }
37
+ }
38
+ function sendJson(response, status, payload, headers) {
39
+ const body = `${JSON.stringify(payload, null, 2)}\n`;
40
+ response.writeHead(status, {
41
+ "content-type": "application/json; charset=utf-8",
42
+ "content-length": Buffer.byteLength(body),
43
+ "cache-control": "no-store",
44
+ ...headers,
45
+ });
46
+ response.end(body);
47
+ }
48
+ function sendHtml(response, html, headers) {
49
+ response.writeHead(200, {
50
+ "content-type": "text/html; charset=utf-8",
51
+ "content-length": Buffer.byteLength(html),
52
+ "cache-control": "no-store",
53
+ ...headers,
54
+ });
55
+ response.end(html);
56
+ }
57
+ async function readJsonBody(request) {
58
+ const chunks = [];
59
+ let size = 0;
60
+ for await (const chunk of request) {
61
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
62
+ size += buffer.length;
63
+ if (size > MAX_BODY_BYTES) {
64
+ throw new MarketApiError(413, `request body is larger than ${MAX_BODY_BYTES} bytes.`);
65
+ }
66
+ chunks.push(buffer);
67
+ }
68
+ const text = Buffer.concat(chunks).toString("utf8").trim();
69
+ if (text.length === 0)
70
+ throw new MarketApiError(400, "request body must be JSON.");
71
+ try {
72
+ return JSON.parse(text);
73
+ }
74
+ catch {
75
+ throw new MarketApiError(400, "request body is not valid JSON.");
76
+ }
77
+ }
78
+ /** A quote with the same amounts in human units, ready to render. */
79
+ function quoteResponse(quote, state, pool) {
80
+ const inDecimals = quote.side === "buy" ? state.quoteDecimals : state.baseDecimals;
81
+ const outDecimals = quote.side === "buy" ? state.baseDecimals : state.quoteDecimals;
82
+ return {
83
+ ...toSwapQuoteJson(quote, state),
84
+ amountInFormatted: formatMarketAmount(quote.amountIn, inDecimals),
85
+ amountOutFormatted: formatMarketAmount(quote.amountOut, outDecimals),
86
+ feeFormatted: formatMarketAmount(quote.fee, inDecimals),
87
+ notionalFormatted: formatMarketAmount(quote.notional, state.quoteDecimals),
88
+ liquidityValueFormatted: formatMarketAmount(state.liquidityValueQuote, state.quoteDecimals),
89
+ executable: quote.executable,
90
+ // What the pool could still settle if this quote were filled, so a page can
91
+ // say how much depth is left instead of guessing from the reserves.
92
+ capacityAfter: capacityJson(pool.capacityAfter(quote)),
93
+ };
94
+ }
95
+ function capacityJson(capacity) {
96
+ return {
97
+ maxBuyBase: capacity.maxBuyBase.toString(),
98
+ maxSellBase: capacity.maxSellBase.toString(),
99
+ payQuote: capacity.payQuote.toString(),
100
+ liquidityValueQuote: capacity.liquidityValueQuote.toString(),
101
+ };
102
+ }
103
+ function capacityResponse(pool) {
104
+ return capacityJson(pool.capacity());
105
+ }
106
+ export class MarketServer {
107
+ pool;
108
+ poolPath;
109
+ options;
110
+ /** Where counters are kept between runs; null keeps them in memory only. */
111
+ limitsPath;
112
+ limitsWriteFailed = false;
113
+ server = null;
114
+ constructor(pool, poolPath, options) {
115
+ if (options.host.trim().length === 0) {
116
+ throw new MarketArgError("--host cannot be empty.");
117
+ }
118
+ if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65_535) {
119
+ throw new MarketArgError("--port must be a whole number between 0 and 65535.");
120
+ }
121
+ if (options.accessKey !== null && options.accessKey.length < MIN_MARKET_KEY_LENGTH) {
122
+ throw new MarketArgError(`--api-key must be at least ${MIN_MARKET_KEY_LENGTH} characters.`);
123
+ }
124
+ this.pool = pool;
125
+ this.poolPath = poolPath;
126
+ this.options = options;
127
+ this.limitsPath =
128
+ options.limitsPath === undefined ? `${poolPath}.limits.json` : options.limitsPath;
129
+ this.loadLimits();
130
+ }
131
+ listen() {
132
+ if (this.server !== null) {
133
+ return Promise.reject(new MarketApiError(500, "the market api is already listening."));
134
+ }
135
+ const server = createServer((request, response) => {
136
+ void this.handle(request, response);
137
+ });
138
+ this.server = server;
139
+ return new Promise((resolve, reject) => {
140
+ const onError = (error) => {
141
+ server.removeListener("listening", onListening);
142
+ this.server = null;
143
+ reject(error);
144
+ };
145
+ const onListening = () => {
146
+ server.removeListener("error", onError);
147
+ const address = server.address();
148
+ const port = typeof address === "object" && address !== null ? address.port : this.options.port;
149
+ const wildcard = this.options.host === "0.0.0.0" || this.options.host === "::";
150
+ const host = wildcard ? "127.0.0.1" : this.options.host;
151
+ resolve({ url: `http://${host}:${port}`, port });
152
+ };
153
+ server.once("error", onError);
154
+ server.once("listening", onListening);
155
+ server.listen(this.options.port, this.options.host);
156
+ });
157
+ }
158
+ close() {
159
+ const server = this.server;
160
+ this.server = null;
161
+ this.saveLimits();
162
+ if (server === null)
163
+ return Promise.resolve();
164
+ return new Promise((resolve, reject) => {
165
+ server.close((error) => (error ? reject(error) : resolve()));
166
+ // A browser keeps connections alive; do not wait for them to time out.
167
+ server.closeAllConnections();
168
+ });
169
+ }
170
+ /** True while the listener is accepting requests. */
171
+ get listening() {
172
+ return this.server !== null;
173
+ }
174
+ /** Requests and failed key attempts per client. */
175
+ clients = new Map();
176
+ /**
177
+ * Read the counters a previous run left behind. A missing, corrupt or expired
178
+ * file is not an error: the API simply starts with a clean slate.
179
+ */
180
+ loadLimits() {
181
+ const path = this.limitsPath;
182
+ if (path === null)
183
+ return;
184
+ let text;
185
+ try {
186
+ text = readFileSync(path, "utf8");
187
+ }
188
+ catch {
189
+ return;
190
+ }
191
+ try {
192
+ const parsed = JSON.parse(text);
193
+ const clients = parsed.clients;
194
+ if (typeof clients !== "object" || clients === null)
195
+ return;
196
+ const now = Date.now();
197
+ for (const [address, value] of Object.entries(clients)) {
198
+ if (typeof value !== "object" || value === null)
199
+ continue;
200
+ const record = value;
201
+ const hits = Array.isArray(record.hits)
202
+ ? record.hits.filter((at) => typeof at === "number" && now - at < RATE_WINDOW_MS)
203
+ : [];
204
+ const failures = Array.isArray(record.failures)
205
+ ? record.failures.filter((at) => typeof at === "number" && now - at < FAILURE_WINDOW_MS)
206
+ : [];
207
+ const blockedUntil = typeof record.blockedUntil === "number" && record.blockedUntil > now
208
+ ? record.blockedUntil
209
+ : 0;
210
+ if (hits.length === 0 && failures.length === 0 && blockedUntil === 0)
211
+ continue;
212
+ this.clients.set(address, { hits, failures, blockedUntil });
213
+ }
214
+ }
215
+ catch {
216
+ // A half-written file is not worth failing a startup over.
217
+ }
218
+ }
219
+ /** Write the counters back, pruning anything that can no longer matter. */
220
+ saveLimits() {
221
+ const path = this.limitsPath;
222
+ if (path === null)
223
+ return;
224
+ const now = Date.now();
225
+ const clients = {};
226
+ for (const [address, record] of this.clients) {
227
+ const hits = record.hits.filter((at) => now - at < RATE_WINDOW_MS);
228
+ const failures = record.failures.filter((at) => now - at < FAILURE_WINDOW_MS);
229
+ const blockedUntil = record.blockedUntil > now ? record.blockedUntil : 0;
230
+ if (hits.length === 0 && failures.length === 0 && blockedUntil === 0)
231
+ continue;
232
+ clients[address] = { hits, failures, blockedUntil };
233
+ }
234
+ try {
235
+ writeFileSync(path, `${JSON.stringify({ version: 1, clients }, null, 2)}\n`);
236
+ }
237
+ catch (error) {
238
+ // A read-only directory is a supported place to run: warn once, keep going.
239
+ if (!this.limitsWriteFailed) {
240
+ this.limitsWriteFailed = true;
241
+ console.error(`lwc: cannot write ${path} (${error instanceof Error ? error.message : String(error)}); rate limits stay in memory.`);
242
+ }
243
+ }
244
+ }
245
+ corsHeaders(origin) {
246
+ const allowed = this.options.corsOrigins;
247
+ if (allowed.length === 0)
248
+ return {};
249
+ const any = allowed.includes("*");
250
+ if (!any && (origin === undefined || !allowed.includes(origin))) {
251
+ // No header: the browser refuses the response, which is the point.
252
+ return {};
253
+ }
254
+ return {
255
+ "access-control-allow-origin": any ? "*" : origin,
256
+ "access-control-allow-methods": "GET, POST, OPTIONS",
257
+ "access-control-allow-headers": "content-type, x-market-key, authorization",
258
+ "access-control-max-age": "600",
259
+ vary: "origin",
260
+ };
261
+ }
262
+ /** True when no key is configured, or the request carries the right one. */
263
+ authorized(request, url) {
264
+ const expected = this.options.accessKey;
265
+ if (expected === null)
266
+ return true;
267
+ const header = request.headers["x-market-key"];
268
+ const supplied = Array.isArray(header) ? header[0] : header;
269
+ if (typeof supplied === "string" && sameSecret(supplied, expected))
270
+ return true;
271
+ const authorization = request.headers.authorization;
272
+ if (typeof authorization === "string" && /^bearer /i.test(authorization)) {
273
+ const token = authorization.slice(7).trim();
274
+ if (token !== "" && sameSecret(token, expected))
275
+ return true;
276
+ }
277
+ // A plain link cannot send a header, so the query string is accepted too; the
278
+ // page moves the key into sessionStorage and removes it from the address bar.
279
+ const query = url.searchParams.get("key");
280
+ return query !== null && sameSecret(query, expected);
281
+ }
282
+ clientRecord(address) {
283
+ let record = this.clients.get(address);
284
+ if (record === undefined) {
285
+ record = { hits: [], failures: [], blockedUntil: 0 };
286
+ this.clients.set(address, record);
287
+ }
288
+ return record;
289
+ }
290
+ /** Seconds this client must wait for guessing the key, or 0 when it may try. */
291
+ blockedSeconds(address) {
292
+ const record = this.clients.get(address);
293
+ if (record === undefined)
294
+ return 0;
295
+ const now = Date.now();
296
+ if (record.blockedUntil > now)
297
+ return Math.ceil((record.blockedUntil - now) / 1000);
298
+ record.failures = record.failures.filter((at) => now - at < FAILURE_WINDOW_MS);
299
+ if (record.failures.length < MAX_AUTH_FAILURES)
300
+ return 0;
301
+ record.blockedUntil = now + FAILURE_WINDOW_MS;
302
+ record.failures = [];
303
+ this.saveLimits();
304
+ return Math.ceil(FAILURE_WINDOW_MS / 1000);
305
+ }
306
+ noteFailure(address) {
307
+ const record = this.clientRecord(address);
308
+ const now = Date.now();
309
+ record.failures = record.failures.filter((at) => now - at < FAILURE_WINDOW_MS);
310
+ record.failures.push(now);
311
+ this.saveLimits();
312
+ }
313
+ /** Seconds until this client may make another request, or 0 for this one. */
314
+ rateLimitWait(address) {
315
+ const limit = this.options.rateLimitPerMinute ?? DEFAULT_MARKET_RATE_LIMIT;
316
+ if (limit <= 0)
317
+ return 0;
318
+ const record = this.clientRecord(address);
319
+ const now = Date.now();
320
+ record.hits = record.hits.filter((at) => now - at < RATE_WINDOW_MS);
321
+ if (record.hits.length >= limit) {
322
+ const oldest = record.hits[0] ?? now;
323
+ return Math.max(1, Math.ceil((RATE_WINDOW_MS - (now - oldest)) / 1000));
324
+ }
325
+ record.hits.push(now);
326
+ this.saveLimits();
327
+ return 0;
328
+ }
329
+ amountFor(side, value, state) {
330
+ const decimals = side === "buy" ? state.quoteDecimals : state.baseDecimals;
331
+ try {
332
+ return parseMarketAmount(value, decimals, "amount");
333
+ }
334
+ catch (error) {
335
+ if (error instanceof MarketArgError)
336
+ throw new MarketApiError(400, error.message);
337
+ throw error;
338
+ }
339
+ }
340
+ async handle(request, response) {
341
+ const cors = this.corsHeaders(request.headers.origin);
342
+ const method = request.method ?? "GET";
343
+ if (method === "OPTIONS") {
344
+ response.writeHead(204, cors);
345
+ response.end();
346
+ return;
347
+ }
348
+ let url;
349
+ try {
350
+ url = new URL(request.url ?? "/", "http://localhost");
351
+ }
352
+ catch {
353
+ sendJson(response, 400, { error: "the request URL is not valid." }, cors);
354
+ return;
355
+ }
356
+ const path = url.pathname.replace(/\/+$/, "") || "/";
357
+ // The page shell carries no pool data, so it stays reachable: it is where a
358
+ // browser enters the key. Every route that reads or moves the pool needs it.
359
+ const publicRoute = path === "/" || path === "/favicon.ico";
360
+ const address = request.socket.remoteAddress ?? "unknown";
361
+ if (!publicRoute) {
362
+ const blocked = this.blockedSeconds(address);
363
+ if (blocked > 0) {
364
+ sendJson(response, 429, {
365
+ error: "too many failed access key attempts; try again shortly.",
366
+ code: "too-many-attempts",
367
+ }, { ...cors, "retry-after": String(blocked) });
368
+ return;
369
+ }
370
+ }
371
+ if (!publicRoute && !this.authorized(request, url)) {
372
+ this.noteFailure(address);
373
+ sendJson(response, 401, { error: "an access key is required.", code: "unauthorized" }, cors);
374
+ return;
375
+ }
376
+ if (!publicRoute) {
377
+ const wait = this.rateLimitWait(address);
378
+ if (wait > 0) {
379
+ sendJson(response, 429, { error: "too many requests; slow down.", code: "rate-limited" }, { ...cors, "retry-after": String(wait) });
380
+ return;
381
+ }
382
+ }
383
+ const state = this.pool.state();
384
+ try {
385
+ if (method === "GET" && path === "/") {
386
+ sendHtml(response, MARKET_PAGE_HTML, cors);
387
+ return;
388
+ }
389
+ if (method === "GET" && path === "/favicon.ico") {
390
+ response.writeHead(204, cors);
391
+ response.end();
392
+ return;
393
+ }
394
+ if (method === "GET" && path === "/health") {
395
+ sendJson(response, 200, {
396
+ ok: true,
397
+ service: "lwc market",
398
+ price: state.price,
399
+ base: state.baseSymbol,
400
+ quote: state.quoteSymbol,
401
+ feeBps: state.feeBps,
402
+ }, cors);
403
+ return;
404
+ }
405
+ if (method === "GET" && path === "/market") {
406
+ sendJson(response, 200, { ...toMarketJson(state, this.poolPath), capacity: capacityResponse(this.pool) }, cors);
407
+ return;
408
+ }
409
+ if (method === "GET" && path === "/quote") {
410
+ const side = url.searchParams.get("side");
411
+ if (side !== "buy" && side !== "sell") {
412
+ throw new MarketApiError(400, "side must be buy or sell.");
413
+ }
414
+ const amount = url.searchParams.get("amount");
415
+ if (amount === null)
416
+ throw new MarketApiError(400, "amount is required.");
417
+ const quote = this.pool.quote(side, this.amountFor(side, amount, state));
418
+ sendJson(response, 200, quoteResponse(quote, state, this.pool), cors);
419
+ return;
420
+ }
421
+ if (path === "/swap") {
422
+ if (method !== "POST") {
423
+ sendJson(response, 405, { error: "swap is a POST route." }, { ...cors, allow: "POST" });
424
+ return;
425
+ }
426
+ const body = await readJsonBody(request);
427
+ if (typeof body !== "object" || body === null) {
428
+ throw new MarketApiError(400, "the body must be a JSON object.");
429
+ }
430
+ const record = body;
431
+ const side = record.side;
432
+ if (side !== "buy" && side !== "sell") {
433
+ throw new MarketApiError(400, "side must be buy or sell.");
434
+ }
435
+ const rawAmount = record.amount;
436
+ if (typeof rawAmount !== "string" && typeof rawAmount !== "number") {
437
+ throw new MarketApiError(400, "amount must be a decimal string.");
438
+ }
439
+ const amount = this.amountFor(side, String(rawAmount), state);
440
+ let filled;
441
+ try {
442
+ filled = this.pool.swap(side, amount);
443
+ }
444
+ catch (error) {
445
+ if (error instanceof MarketError) {
446
+ // The quote already explains the refusal; hand it back with the error.
447
+ const quote = quoteResponse(this.pool.quote(side, amount), state, this.pool);
448
+ sendJson(response, 422, { error: error.message, code: error.code, quote }, cors);
449
+ return;
450
+ }
451
+ throw error;
452
+ }
453
+ savePoolFile(this.poolPath, this.pool, { force: true });
454
+ sendJson(response, 201, {
455
+ ...quoteResponse(filled, state, this.pool),
456
+ state: {
457
+ ...toMarketJson(this.pool.state(), this.poolPath),
458
+ capacity: capacityResponse(this.pool),
459
+ },
460
+ }, cors);
461
+ return;
462
+ }
463
+ sendJson(response, 404, { error: `no route for ${method} ${path}.` }, cors);
464
+ }
465
+ catch (error) {
466
+ if (error instanceof MarketApiError) {
467
+ sendJson(response, error.status, { error: error.message, ...(error.code === null ? {} : { code: error.code }) }, cors);
468
+ return;
469
+ }
470
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) }, cors);
471
+ }
472
+ }
473
+ }