@livo-build/runtime 0.2.6 → 0.2.12

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.
@@ -0,0 +1,131 @@
1
+ interface MinimalEnv {
2
+ [key: string]: unknown;
3
+ }
4
+ /** The public Livo signals engine. Override via `{ url }` or `SIGNALS_URL`. */
5
+ export declare const DEFAULT_SIGNALS_URL = "https://signals.livo.build";
6
+ export interface SignalsOptions {
7
+ /** Override the engine base URL (takes precedence over env). */
8
+ url?: string;
9
+ /** env key holding the engine base URL. Default `SIGNALS_URL`. */
10
+ urlSecret?: string;
11
+ }
12
+ /** A live, above-floor market the engine is metering. USD figures are dollars;
13
+ * `chg*` are percent; `spark` is recent candle closes (oldest→newest). */
14
+ export interface Market {
15
+ pair: string;
16
+ base: string;
17
+ quote: string;
18
+ pool: string;
19
+ price: number;
20
+ liq: number;
21
+ vol5m: number;
22
+ vol1h: number;
23
+ vol24h: number;
24
+ trades5m: number;
25
+ buyers: number;
26
+ chg5m: number;
27
+ chg1h: number;
28
+ chg24h: number;
29
+ spark: number[];
30
+ }
31
+ /** A fired signal-match (the public house feed: whale_buy/sell, price_surge, new_pool, …). */
32
+ export interface SignalMatch {
33
+ signal: string;
34
+ detail: string;
35
+ pair: string;
36
+ base: string;
37
+ pool: string;
38
+ side: string;
39
+ usd: number;
40
+ actor: string;
41
+ ago: number;
42
+ }
43
+ /** A recent priced swap from the firehose. */
44
+ export interface Swap {
45
+ block: number;
46
+ pair: string;
47
+ base: string;
48
+ pool: string;
49
+ side: string;
50
+ usd: number;
51
+ conf: string;
52
+ ago: number;
53
+ }
54
+ /** A newly-launched pool (young + climbing). `age` seconds, `pct` since launch. */
55
+ export interface Launch {
56
+ pair: string;
57
+ base: string;
58
+ pool: string;
59
+ age: number;
60
+ price: number;
61
+ pct: number;
62
+ vol: number;
63
+ buyers: number;
64
+ liq: number;
65
+ }
66
+ /** The full engine snapshot (the `/data` document). */
67
+ export interface EngineSnapshot {
68
+ mode: string;
69
+ caught_up: boolean;
70
+ head_block: number;
71
+ markets_tracked: number;
72
+ matches_total: number;
73
+ swaps_total: number;
74
+ markets: Market[];
75
+ matches: SignalMatch[];
76
+ swaps: Swap[];
77
+ launches: Launch[];
78
+ [key: string]: unknown;
79
+ }
80
+ export interface MarketFilter {
81
+ /** Min real TVL (USD). */
82
+ minLiq?: number;
83
+ /** Min 5-minute volume (USD) — "heating up right now". */
84
+ minVol5m?: number;
85
+ /** Min 24h volume (USD). */
86
+ minVol24h?: number;
87
+ /** Substring match on the pair, case-insensitive (e.g. "WETH"). */
88
+ pair?: string;
89
+ /** Cap the number returned (already sorted by 5m volume, desc). */
90
+ limit?: number;
91
+ }
92
+ export declare class Signals {
93
+ readonly url: string;
94
+ constructor(env?: MinimalEnv, options?: SignalsOptions);
95
+ /** The raw engine snapshot (markets + recent swaps + fired matches + launches). */
96
+ data(): Promise<EngineSnapshot>;
97
+ /** Live markets, newest-hottest first, optionally filtered. */
98
+ markets(filter?: MarketFilter): Promise<Market[]>;
99
+ /** One market by pool OR base-token address (0x-insensitive). undefined if absent. */
100
+ market(poolOrToken: string): Promise<Market | undefined>;
101
+ /** Find a token's deepest market by address or pair symbol (e.g. "PEPE"). */
102
+ token(addressOrSymbol: string): Promise<Market | undefined>;
103
+ /** Recently fired public signal-matches, newest first. */
104
+ matches(opts?: {
105
+ signal?: string;
106
+ limit?: number;
107
+ }): Promise<SignalMatch[]>;
108
+ /** Recent priced swaps (firehose), newest first. */
109
+ swaps(opts?: {
110
+ minUsd?: number;
111
+ side?: "BUY" | "SELL";
112
+ limit?: number;
113
+ }): Promise<Swap[]>;
114
+ /** Newly-launched pools (young + climbing). */
115
+ launches(): Promise<Launch[]>;
116
+ /** True if the engine is live and caught up to the chain tip. */
117
+ health(): Promise<boolean>;
118
+ /**
119
+ * Subscribe to the live Server-Sent-Events stream (for long-lived servers /
120
+ * Durable Objects). `onEvent` is called with each event's topic
121
+ * (`stats` | `swap` | `signal` | `pending`) and parsed JSON payload. Runs until
122
+ * the stream closes or `signal` (AbortSignal) aborts. Keepers should poll
123
+ * `markets()`/`matches()` on their schedule instead.
124
+ */
125
+ stream(onEvent: (topic: string, data: unknown) => void | Promise<void>, opts?: {
126
+ signal?: AbortSignal;
127
+ }): Promise<void>;
128
+ }
129
+ /** Convenience: `await signals(env).markets({ minVol5m: 50_000 })`. */
130
+ export declare function signals(env?: MinimalEnv, options?: SignalsOptions): Signals;
131
+ export {};
@@ -0,0 +1,146 @@
1
+ // Signals — a read client for the Livo on-chain signals engine ("Signal Radar").
2
+ // The engine is a SHARED public service (mainnet DEX activity, priced + metered):
3
+ // live markets, recent swaps, and fired signal-matches. This is the PULL side —
4
+ // poll it from a keeper or read it from a server/bot. The PUSH side (get a webhook
5
+ // when a signal fires for YOUR token) is `defineWatcher` + the create_watcher tool.
6
+ //
7
+ // import { signals } from "@livo-build/runtime";
8
+ // const sig = signals(env); // zero-config → signals.livo.build
9
+ // const hot = await sig.markets({ minVol5m: 50_000 }); // markets heating up now
10
+ // const m = await sig.token("0x6982…"); // one token's market
11
+ // const fired = await sig.matches({ signal: "whale_buy" });
12
+ //
13
+ // Zero config: defaults to the public engine, so it works out of the box. Override
14
+ // with { url } or a SIGNALS_URL binding for a private/local engine.
15
+ /** The public Livo signals engine. Override via `{ url }` or `SIGNALS_URL`. */
16
+ export const DEFAULT_SIGNALS_URL = "https://signals.livo.build";
17
+ const bare = (a) => a.replace(/^0x/, "").toLowerCase();
18
+ export class Signals {
19
+ url;
20
+ constructor(env, options = {}) {
21
+ const fromEnv = env?.[options.urlSecret ?? "SIGNALS_URL"];
22
+ this.url = (options.url ?? fromEnv ?? DEFAULT_SIGNALS_URL).replace(/\/$/, "");
23
+ }
24
+ /** The raw engine snapshot (markets + recent swaps + fired matches + launches). */
25
+ async data() {
26
+ const res = await fetch(`${this.url}/data`, { headers: { accept: "application/json" } });
27
+ if (!res.ok) {
28
+ throw new Error(`signals engine /data failed: HTTP ${res.status}`);
29
+ }
30
+ return (await res.json());
31
+ }
32
+ /** Live markets, newest-hottest first, optionally filtered. */
33
+ async markets(filter = {}) {
34
+ let rows = (await this.data()).markets ?? [];
35
+ if (filter.minLiq != null)
36
+ rows = rows.filter((m) => m.liq >= filter.minLiq);
37
+ if (filter.minVol5m != null)
38
+ rows = rows.filter((m) => m.vol5m >= filter.minVol5m);
39
+ if (filter.minVol24h != null)
40
+ rows = rows.filter((m) => m.vol24h >= filter.minVol24h);
41
+ if (filter.pair) {
42
+ const q = filter.pair.toLowerCase();
43
+ rows = rows.filter((m) => m.pair.toLowerCase().includes(q));
44
+ }
45
+ return filter.limit != null ? rows.slice(0, filter.limit) : rows;
46
+ }
47
+ /** One market by pool OR base-token address (0x-insensitive). undefined if absent. */
48
+ async market(poolOrToken) {
49
+ const q = bare(poolOrToken);
50
+ return (await this.data()).markets?.find((m) => bare(m.pool) === q || bare(m.base) === q);
51
+ }
52
+ /** Find a token's deepest market by address or pair symbol (e.g. "PEPE"). */
53
+ async token(addressOrSymbol) {
54
+ const rows = (await this.data()).markets ?? [];
55
+ const q = addressOrSymbol.toLowerCase();
56
+ const byAddr = rows.find((m) => bare(m.base) === bare(addressOrSymbol));
57
+ if (byAddr)
58
+ return byAddr;
59
+ // Symbol match → the deepest (highest-liq) market for that base symbol.
60
+ const matches = rows.filter((m) => m.pair.toLowerCase().split("/")[0] === q);
61
+ return matches.sort((a, b) => b.liq - a.liq)[0];
62
+ }
63
+ /** Recently fired public signal-matches, newest first. */
64
+ async matches(opts = {}) {
65
+ let rows = (await this.data()).matches ?? [];
66
+ if (opts.signal)
67
+ rows = rows.filter((m) => m.signal === opts.signal);
68
+ return opts.limit != null ? rows.slice(0, opts.limit) : rows;
69
+ }
70
+ /** Recent priced swaps (firehose), newest first. */
71
+ async swaps(opts = {}) {
72
+ let rows = (await this.data()).swaps ?? [];
73
+ if (opts.minUsd != null)
74
+ rows = rows.filter((s) => s.usd >= opts.minUsd);
75
+ if (opts.side)
76
+ rows = rows.filter((s) => s.side === opts.side);
77
+ return opts.limit != null ? rows.slice(0, opts.limit) : rows;
78
+ }
79
+ /** Newly-launched pools (young + climbing). */
80
+ async launches() {
81
+ return (await this.data()).launches ?? [];
82
+ }
83
+ /** True if the engine is live and caught up to the chain tip. */
84
+ async health() {
85
+ try {
86
+ const res = await fetch(`${this.url}/health`);
87
+ return res.ok;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ /**
94
+ * Subscribe to the live Server-Sent-Events stream (for long-lived servers /
95
+ * Durable Objects). `onEvent` is called with each event's topic
96
+ * (`stats` | `swap` | `signal` | `pending`) and parsed JSON payload. Runs until
97
+ * the stream closes or `signal` (AbortSignal) aborts. Keepers should poll
98
+ * `markets()`/`matches()` on their schedule instead.
99
+ */
100
+ async stream(onEvent, opts = {}) {
101
+ const res = await fetch(`${this.url}/stream`, {
102
+ headers: { accept: "text/event-stream" },
103
+ signal: opts.signal,
104
+ });
105
+ if (!res.ok || !res.body) {
106
+ throw new Error(`signals engine /stream failed: HTTP ${res.status}`);
107
+ }
108
+ const reader = res.body.getReader();
109
+ const decoder = new TextDecoder();
110
+ let buf = "";
111
+ for (;;) {
112
+ const { value, done } = await reader.read();
113
+ if (done)
114
+ break;
115
+ buf += decoder.decode(value, { stream: true });
116
+ // SSE frames are separated by a blank line; each has `event:` + `data:` lines.
117
+ let sep;
118
+ while ((sep = buf.indexOf("\n\n")) !== -1) {
119
+ const frame = buf.slice(0, sep);
120
+ buf = buf.slice(sep + 2);
121
+ let topic = "message";
122
+ const dataLines = [];
123
+ for (const line of frame.split("\n")) {
124
+ if (line.startsWith("event:"))
125
+ topic = line.slice(6).trim();
126
+ else if (line.startsWith("data:"))
127
+ dataLines.push(line.slice(5).trim());
128
+ }
129
+ if (!dataLines.length)
130
+ continue;
131
+ let payload = dataLines.join("\n");
132
+ try {
133
+ payload = JSON.parse(payload);
134
+ }
135
+ catch {
136
+ /* leave as string */
137
+ }
138
+ await onEvent(topic, payload);
139
+ }
140
+ }
141
+ }
142
+ }
143
+ /** Convenience: `await signals(env).markets({ minVol5m: 50_000 })`. */
144
+ export function signals(env, options) {
145
+ return new Signals(env, options);
146
+ }
package/dist/siwe.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ export interface SiweParams {
2
+ /** The domain requesting the sign-in (e.g. "app.livo.build"). */
3
+ domain: string;
4
+ address: string;
5
+ uri: string;
6
+ chainId: number;
7
+ nonce: string;
8
+ statement?: string;
9
+ /** ISO timestamp; defaults to now. */
10
+ issuedAt?: string;
11
+ /** ISO timestamp; if set, verifySiweMessage rejects after it. */
12
+ expirationTime?: string;
13
+ version?: string;
14
+ }
15
+ export declare function createSiweMessage(p: SiweParams): string;
16
+ export interface SiweVerifyResult {
17
+ valid: boolean;
18
+ address?: string;
19
+ nonce?: string;
20
+ }
21
+ export declare function verifySiweMessage(message: string, signature: string, opts?: {
22
+ nonce?: string;
23
+ domain?: string;
24
+ }): Promise<SiweVerifyResult>;
package/dist/siwe.js ADDED
@@ -0,0 +1,33 @@
1
+ // Sign-In With Ethereum (EIP-4361) — build the canonical message + verify a signed
2
+ // one. The standard way to authenticate a wallet to a backend; pairs with sessions.
3
+ import { verifyMessage } from "./sig.js";
4
+ export function createSiweMessage(p) {
5
+ const lines = [`${p.domain} wants you to sign in with your Ethereum account:`, p.address, ""];
6
+ if (p.statement)
7
+ lines.push(p.statement, "");
8
+ lines.push(`URI: ${p.uri}`, `Version: ${p.version ?? "1"}`, `Chain ID: ${p.chainId}`, `Nonce: ${p.nonce}`, `Issued At: ${p.issuedAt ?? new Date().toISOString()}`);
9
+ if (p.expirationTime)
10
+ lines.push(`Expiration Time: ${p.expirationTime}`);
11
+ return lines.join("\n");
12
+ }
13
+ function field(message, label) {
14
+ const m = new RegExp(`^${label}(.*)$`, "m").exec(message);
15
+ return m?.[1]?.trim();
16
+ }
17
+ // Verify a SIWE message + signature: the signature recovers to the claimed address,
18
+ // the nonce matches (if given), and it isn't expired.
19
+ export async function verifySiweMessage(message, signature, opts = {}) {
20
+ const address = message.split("\n")[1]?.trim();
21
+ if (!address || !/^0x[0-9a-fA-F]{40}$/.test(address))
22
+ return { valid: false };
23
+ const nonce = field(message, "Nonce: ");
24
+ if (opts.nonce && nonce !== opts.nonce)
25
+ return { valid: false };
26
+ if (opts.domain && !message.startsWith(opts.domain + " "))
27
+ return { valid: false };
28
+ const exp = field(message, "Expiration Time: ");
29
+ if (exp && Date.parse(exp) < Date.now())
30
+ return { valid: false };
31
+ const ok = await verifyMessage({ address: address, message, signature: signature });
32
+ return { valid: ok, address: ok ? address : undefined, nonce };
33
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { sse } from "./http.js";
3
+ async function readAll(res) {
4
+ return await res.text();
5
+ }
6
+ describe("sse", () => {
7
+ it("formats events (data/event/id) and a heartbeat, and sets the right headers", async () => {
8
+ const conn = sse();
9
+ expect(conn.response.headers.get("content-type")).toBe("text/event-stream");
10
+ conn.send({ px: 100 });
11
+ conn.send("hello", { event: "tick", id: "7" });
12
+ conn.ping();
13
+ conn.send("a\nb"); // multiline → two data: lines
14
+ conn.close();
15
+ const text = await readAll(conn.response);
16
+ expect(text).toContain('data: {"px":100}\n\n');
17
+ expect(text).toContain("event: tick\nid: 7\ndata: hello\n\n");
18
+ expect(text).toContain(": ping\n\n");
19
+ expect(text).toContain("data: a\ndata: b\n\n");
20
+ expect(conn.closed).toBe(true);
21
+ });
22
+ it("stops writing after close (no throw)", async () => {
23
+ const conn = sse();
24
+ conn.close();
25
+ conn.send({ ignored: true });
26
+ expect(await readAll(conn.response)).toBe("");
27
+ });
28
+ });
@@ -0,0 +1,18 @@
1
+ export type SignatureEncoding = "hex" | "base64";
2
+ /** Compute the HMAC-SHA256 signature of a payload (for signing outgoing webhooks/tests). */
3
+ export declare function signWebhook(payload: string, secret: string, encoding?: SignatureEncoding): Promise<string>;
4
+ export interface VerifyWebhookParams {
5
+ /** The RAW request body (verify before JSON.parse — re-serialization changes bytes). */
6
+ payload: string;
7
+ secret: string;
8
+ /** The signature header value. A `scheme=` prefix (e.g. "sha256=") is stripped for hex. */
9
+ signature: string;
10
+ /** Digest encoding of `signature` (default "hex"). */
11
+ encoding?: SignatureEncoding;
12
+ }
13
+ /**
14
+ * Verify a webhook's HMAC-SHA256 signature against the raw body. Returns true iff the
15
+ * signature matches. For hex signatures a leading `algo=` prefix (GitHub's "sha256=")
16
+ * is stripped automatically.
17
+ */
18
+ export declare function verifyWebhook(params: VerifyWebhookParams): Promise<boolean>;
@@ -0,0 +1,49 @@
1
+ // Inbound webhook signature verification (GitHub/Stripe-style HMAC-SHA256). Verify
2
+ // that a raw request body was signed with your shared secret before trusting it.
3
+ // Constant-time comparison; no hand-rolled crypto.
4
+ function enc(s) {
5
+ return new TextEncoder().encode(s);
6
+ }
7
+ function toHex(bytes) {
8
+ let out = "";
9
+ for (const b of bytes)
10
+ out += b.toString(16).padStart(2, "0");
11
+ return out;
12
+ }
13
+ function toBase64(bytes) {
14
+ let bin = "";
15
+ for (const b of bytes)
16
+ bin += String.fromCharCode(b);
17
+ return btoa(bin);
18
+ }
19
+ async function hmac(secret, payload) {
20
+ const key = await crypto.subtle.importKey("raw", enc(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
21
+ return new Uint8Array(await crypto.subtle.sign("HMAC", key, enc(payload)));
22
+ }
23
+ /** Constant-time string compare (length-independent leak is acceptable for digests). */
24
+ function timingSafeEqual(a, b) {
25
+ if (a.length !== b.length)
26
+ return false;
27
+ let diff = 0;
28
+ for (let i = 0; i < a.length; i++)
29
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
30
+ return diff === 0;
31
+ }
32
+ /** Compute the HMAC-SHA256 signature of a payload (for signing outgoing webhooks/tests). */
33
+ export async function signWebhook(payload, secret, encoding = "hex") {
34
+ const mac = await hmac(secret, payload);
35
+ return encoding === "base64" ? toBase64(mac) : toHex(mac);
36
+ }
37
+ /**
38
+ * Verify a webhook's HMAC-SHA256 signature against the raw body. Returns true iff the
39
+ * signature matches. For hex signatures a leading `algo=` prefix (GitHub's "sha256=")
40
+ * is stripped automatically.
41
+ */
42
+ export async function verifyWebhook(params) {
43
+ const encoding = params.encoding ?? "hex";
44
+ let provided = params.signature.trim();
45
+ if (encoding === "hex")
46
+ provided = provided.replace(/^[A-Za-z0-9_-]+=/, ""); // strip "sha256=" etc (hex has no '=')
47
+ const expected = await signWebhook(params.payload, params.secret, encoding);
48
+ return timingSafeEqual(expected.toLowerCase(), provided.toLowerCase());
49
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { signWebhook, verifyWebhook } from "./webhook.js";
3
+ import { cached } from "./cache.js";
4
+ describe("webhook signatures", () => {
5
+ it("round-trips a hex signature and strips a sha256= prefix", async () => {
6
+ const sig = await signWebhook("payload-body", "shh");
7
+ expect(await verifyWebhook({ payload: "payload-body", secret: "shh", signature: sig })).toBe(true);
8
+ expect(await verifyWebhook({ payload: "payload-body", secret: "shh", signature: "sha256=" + sig })).toBe(true);
9
+ });
10
+ it("rejects a wrong secret or tampered payload", async () => {
11
+ const sig = await signWebhook("a", "s1");
12
+ expect(await verifyWebhook({ payload: "a", secret: "s2", signature: sig })).toBe(false);
13
+ expect(await verifyWebhook({ payload: "b", secret: "s1", signature: sig })).toBe(false);
14
+ });
15
+ it("supports base64 encoding", async () => {
16
+ const sig = await signWebhook("x", "k", "base64");
17
+ expect(await verifyWebhook({ payload: "x", secret: "k", signature: sig, encoding: "base64" })).toBe(true);
18
+ });
19
+ });
20
+ function memKv() {
21
+ const store = new Map();
22
+ return {
23
+ store,
24
+ get: async (k) => store.get(k) ?? null,
25
+ put: async (k, v) => void store.set(k, v),
26
+ };
27
+ }
28
+ describe("cached", () => {
29
+ it("computes once, then serves from cache", async () => {
30
+ const kv = memKv();
31
+ let calls = 0;
32
+ const produce = async () => {
33
+ calls++;
34
+ return { n: 42 };
35
+ };
36
+ expect(await cached(kv, "k", produce, { ttlSeconds: 60 })).toEqual({ n: 42 });
37
+ expect(await cached(kv, "k", produce, { ttlSeconds: 60 })).toEqual({ n: 42 });
38
+ expect(calls).toBe(1);
39
+ expect(kv.store.has("cache:k")).toBe(true);
40
+ });
41
+ it("recomputes when the cached value is corrupt", async () => {
42
+ const kv = memKv();
43
+ kv.store.set("cache:bad", "{not json");
44
+ expect(await cached(kv, "bad", async () => "fresh", { ttlSeconds: 60 })).toBe("fresh");
45
+ });
46
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livo-build/runtime",
3
- "version": "0.2.6",
3
+ "version": "0.2.12",
4
4
  "description": "Livo runtime — chain signing/reads, D1 state, and logging for keepers, servers, and bots.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",