@livo-build/runtime 0.2.3 → 0.2.5

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/dist/sig.js ADDED
@@ -0,0 +1,50 @@
1
+ // EIP-191 personal_sign message verification — prove a user controls an address
2
+ // from a signature, with zero hand-rolled crypto. The counterpart to tx.ts's
3
+ // signing: here we RECOVER the signer of a `personal_sign` message (what
4
+ // wallets produce for `eth_sign`/`personal_sign` and SIWE login). secp256k1
5
+ // recovery via noble; same keccak + hex helpers as the rest of the runtime.
6
+ import { secp256k1 } from "@noble/curves/secp256k1";
7
+ import { keccak_256 } from "@noble/hashes/sha3";
8
+ import { bytesToHex, concatBytes, hexToBytes } from "./hex.js";
9
+ import { toChecksumAddress } from "./tx.js";
10
+ const PERSONAL_PREFIX = "\x19Ethereum Signed Message:\n";
11
+ /**
12
+ * The EIP-191 digest a wallet signs for `personal_sign(message)`:
13
+ * keccak256("\x19Ethereum Signed Message:\n" + byteLength(message) + message)
14
+ * `message` is treated as UTF-8 text (the common case for login/link flows).
15
+ */
16
+ export function hashMessage(message) {
17
+ const body = new TextEncoder().encode(message);
18
+ const prefix = new TextEncoder().encode(PERSONAL_PREFIX + body.length);
19
+ return keccak_256(concatBytes(prefix, body));
20
+ }
21
+ /**
22
+ * Recover the EIP-55 checksummed address that produced `signature` over
23
+ * `message` (an EIP-191 personal_sign). `signature` is the 65-byte 0x string
24
+ * r(32) ++ s(32) ++ v(1), where v is 27/28 (or 0/1). Throws on a malformed sig.
25
+ */
26
+ export function recoverMessageAddress(message, signature) {
27
+ const bytes = hexToBytes(signature);
28
+ if (bytes.length !== 65)
29
+ throw new Error("signature must be 65 bytes (r,s,v)");
30
+ const v = bytes[64];
31
+ const recovery = v >= 27 ? v - 27 : v;
32
+ if (recovery !== 0 && recovery !== 1)
33
+ throw new Error(`invalid signature recovery byte: ${v}`);
34
+ const sig = secp256k1.Signature.fromCompact(bytes.slice(0, 64)).addRecoveryBit(recovery);
35
+ const pub = sig.recoverPublicKey(hashMessage(message)).toRawBytes(false).slice(1); // drop 0x04
36
+ return toChecksumAddress(bytesToHex(keccak_256(pub).slice(-20)));
37
+ }
38
+ /**
39
+ * True iff `signature` over `message` was produced by `address` — the safe way to
40
+ * verify "prove you own this wallet" server-side. Never throws: a malformed
41
+ * signature returns false.
42
+ */
43
+ export function verifyMessage({ address, message, signature }) {
44
+ try {
45
+ return recoverMessageAddress(message, signature).toLowerCase() === address.toLowerCase();
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
@@ -54,6 +54,14 @@ export declare class Telegram {
54
54
  handleUpdate(req: Request, reply: (msg: BotMessage) => string | null | undefined | Promise<string | null | undefined>, opts?: SendMessageOptions): Promise<Response>;
55
55
  /** Send a message to a chat. No-op-safe: throws if no token is configured. */
56
56
  sendMessage(chatId: number | string, text: string, opts?: SendMessageOptions): Promise<void>;
57
+ /**
58
+ * Send a photo to a chat. `photo` is a URL Telegram fetches, or a Telegram
59
+ * file_id. (To upload raw bytes, post multipart/form-data to sendPhoto directly.)
60
+ */
61
+ sendPhoto(chatId: number | string, photo: string, opts?: {
62
+ caption?: string;
63
+ parseMode?: "Markdown" | "MarkdownV2" | "HTML";
64
+ }): Promise<void>;
57
65
  /** Register the slash-command menu (setMyCommands) — autocompletes in chat. */
58
66
  setMyCommands(commands: Array<{
59
67
  command: string;
package/dist/telegram.js CHANGED
@@ -102,6 +102,26 @@ export class Telegram {
102
102
  if (!res.ok)
103
103
  throw new Error(`Telegram sendMessage failed: HTTP ${res.status}`);
104
104
  }
105
+ /**
106
+ * Send a photo to a chat. `photo` is a URL Telegram fetches, or a Telegram
107
+ * file_id. (To upload raw bytes, post multipart/form-data to sendPhoto directly.)
108
+ */
109
+ async sendPhoto(chatId, photo, opts = {}) {
110
+ if (!this.token)
111
+ throw new Error("Telegram: no bot token (set_secret BOT_TOKEN=<token>).");
112
+ const body = { chat_id: chatId, photo };
113
+ if (opts.caption)
114
+ body.caption = opts.caption;
115
+ if (opts.parseMode)
116
+ body.parse_mode = opts.parseMode;
117
+ const res = await fetch(`${API}${this.token}/sendPhoto`, {
118
+ method: "POST",
119
+ headers: { "content-type": "application/json" },
120
+ body: JSON.stringify(body),
121
+ });
122
+ if (!res.ok)
123
+ throw new Error(`Telegram sendPhoto failed: HTTP ${res.status}`);
124
+ }
105
125
  /** Register the slash-command menu (setMyCommands) — autocompletes in chat. */
106
126
  async setMyCommands(commands) {
107
127
  if (!this.token)
@@ -0,0 +1,83 @@
1
+ interface MinimalEnv {
2
+ [key: string]: unknown;
3
+ }
4
+ /** Bindings the platform injects into a deployed watcher Worker. */
5
+ export interface WatcherEnv extends MinimalEnv {
6
+ LIVO_WATCHER_DISPATCH_TOKEN?: string;
7
+ LIVO_SIGNAL_SUBSCRIBE_URL?: string;
8
+ LIVO_SIGNAL_CANCEL_URL?: string;
9
+ LIVO_WATCHER_URL?: string;
10
+ LIVO_PROJECT_ID?: string;
11
+ LIVO_WATCHER_NAME?: string;
12
+ LIVO_CHAIN_ID?: string;
13
+ LIVO_RUN_URL?: string;
14
+ LIVO_RUN_TOKEN?: string;
15
+ }
16
+ export type Confidence = "high" | "medium" | "low" | "none";
17
+ export type MatchStatus = "confirmed" | "reverted";
18
+ /** A delivered match (the engine-emitted, priced event that fired a signal). */
19
+ export interface WatcherMatch {
20
+ deliveryKey: string;
21
+ matchKey: string;
22
+ status: MatchStatus;
23
+ signalId: string;
24
+ subId: number;
25
+ watcherId: number;
26
+ market: {
27
+ base: string;
28
+ quote: string;
29
+ pool: string;
30
+ };
31
+ event: {
32
+ actor: string;
33
+ kind: string;
34
+ ts: number;
35
+ amountUsdMicros: number | null;
36
+ tokenUsdMicros: number | null;
37
+ confidence: Confidence;
38
+ deviationBps: number | null;
39
+ eventId: {
40
+ blockHash: string;
41
+ txHash: string;
42
+ logIndex: number;
43
+ };
44
+ };
45
+ }
46
+ /** A subscription scope (token / watchlist / actor / global). */
47
+ export interface WatcherScope {
48
+ kind: "token" | "watchlist" | "actor" | "global";
49
+ market?: string;
50
+ markets?: string[];
51
+ actor?: string;
52
+ }
53
+ export interface SubscribeOptions {
54
+ /** Stable id for this subscription (and self-cancel handle). */
55
+ subKey: string;
56
+ signalId: string;
57
+ scope: WatcherScope;
58
+ params?: Record<string, unknown>;
59
+ /** Where matches deliver. Defaults to this watcher's own onMatch URL. */
60
+ dispatchUrl?: string;
61
+ chain?: number;
62
+ /** Time-sensitive (stop-loss/take-profit) — armed immediately. Default true. */
63
+ ephemeral?: boolean;
64
+ }
65
+ /** Context passed to onMatch: env + runtime subscribe/cancel (exit-triggers). */
66
+ export interface WatcherContext {
67
+ env: WatcherEnv;
68
+ /** Arm a new subscription now (e.g. a stop-loss at fill time). */
69
+ subscribe(opts: SubscribeOptions): Promise<boolean>;
70
+ /** Remove a subscription by its subKey (self-cancel on fire). */
71
+ cancel(subKey: string): Promise<boolean>;
72
+ }
73
+ export interface WatcherDefinition {
74
+ /** The signal this watcher reacts to (informational; the platform routes). */
75
+ signal?: string;
76
+ onMatch: (match: WatcherMatch, ctx: WatcherContext) => Promise<void> | void;
77
+ }
78
+ /** A deployable Worker module ({ fetch }) — the shape Cloudflare invokes. */
79
+ export interface WatcherModule {
80
+ fetch(req: Request, env: WatcherEnv, ctx?: unknown): Promise<Response>;
81
+ }
82
+ export declare function defineWatcher(def: WatcherDefinition): WatcherModule;
83
+ export {};
@@ -0,0 +1,155 @@
1
+ // defineWatcher — the watcher-facing primitive for Signal Radar (the onMatch
2
+ // counterpart to keepers' scheduled()). A watcher is an HTTP-triggered Worker:
3
+ // the platform delivers ONE subscription-addressed match per request to its
4
+ // onMatch handler (SIGNAL-RADAR-IMPL-SPEC.md §10–§11).
5
+ //
6
+ // Auth mirrors the rest of the runtime (bearer tokens, not hand-rolled crypto):
7
+ // each delivery carries the watcher's dispatchToken as a bearer, injected at
8
+ // deploy as LIVO_WATCHER_DISPATCH_TOKEN. The same token authenticates this
9
+ // watcher's ctx.subscribe()/cancel() calls. (HMAC-signing the body is a later
10
+ // hardening step; the bearer scopes a leak to this one watcher today.)
11
+ export function defineWatcher(def) {
12
+ return {
13
+ async fetch(req, env, _ctx) {
14
+ if (req.method !== "POST")
15
+ return jsonResponse({ error: "method_not_allowed" }, 405);
16
+ // Per-watcher dispatch auth. Fail closed if no token is configured.
17
+ const expected = env.LIVO_WATCHER_DISPATCH_TOKEN;
18
+ if (!expected || bearer(req) !== expected) {
19
+ return jsonResponse({ error: "unauthorized" }, 401);
20
+ }
21
+ let match;
22
+ try {
23
+ match = parseMatch(await req.json());
24
+ }
25
+ catch {
26
+ return jsonResponse({ error: "bad_request" }, 400);
27
+ }
28
+ const ctx = makeContext(env);
29
+ const startedAt = Date.now();
30
+ try {
31
+ await def.onMatch(match, ctx);
32
+ await reportRun(env, startedAt, true);
33
+ return jsonResponse({ ok: true }, 200);
34
+ }
35
+ catch (e) {
36
+ await reportRun(env, startedAt, false, String(e));
37
+ return jsonResponse({ ok: false }, 500);
38
+ }
39
+ },
40
+ };
41
+ }
42
+ // ---------------------------------------------------------------------------
43
+ // Internals.
44
+ // ---------------------------------------------------------------------------
45
+ function makeContext(env) {
46
+ const token = env.LIVO_WATCHER_DISPATCH_TOKEN ?? "";
47
+ return {
48
+ env,
49
+ async subscribe(opts) {
50
+ const url = env.LIVO_SIGNAL_SUBSCRIBE_URL;
51
+ if (!url)
52
+ return false;
53
+ const res = await fetch(url, {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
56
+ body: JSON.stringify({
57
+ projectId: env.LIVO_PROJECT_ID,
58
+ watcherName: env.LIVO_WATCHER_NAME,
59
+ chain: opts.chain ?? Number(env.LIVO_CHAIN_ID ?? "1"),
60
+ signalId: opts.signalId,
61
+ scope: opts.scope,
62
+ params: opts.params ?? {},
63
+ dispatchUrl: opts.dispatchUrl ?? env.LIVO_WATCHER_URL ?? "",
64
+ subKey: opts.subKey,
65
+ ephemeral: opts.ephemeral ?? true,
66
+ }),
67
+ }).catch(() => undefined);
68
+ return Boolean(res && res.ok);
69
+ },
70
+ async cancel(subKey) {
71
+ const url = env.LIVO_SIGNAL_CANCEL_URL;
72
+ if (!url)
73
+ return false;
74
+ const res = await fetch(url, {
75
+ method: "POST",
76
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
77
+ body: JSON.stringify({ subKey }),
78
+ }).catch(() => undefined);
79
+ return Boolean(res && res.ok);
80
+ },
81
+ };
82
+ }
83
+ function bearer(req) {
84
+ const h = req.headers.get("authorization") ?? req.headers.get("Authorization");
85
+ if (!h)
86
+ return null;
87
+ const m = h.match(/^Bearer\s+(.+)$/i);
88
+ return m ? m[1] : null;
89
+ }
90
+ function jsonResponse(body, status) {
91
+ return new Response(JSON.stringify(body), {
92
+ status,
93
+ headers: { "content-type": "application/json" },
94
+ });
95
+ }
96
+ /** Parse the platform's wire envelope (snake_case JSON) into a typed match. */
97
+ function parseMatch(raw) {
98
+ const b = raw;
99
+ const ev = (b.event ?? {});
100
+ const eid = (ev.event_id ?? {});
101
+ if (typeof b.delivery_key !== "string" || typeof b.match_key !== "string") {
102
+ throw new Error("malformed match envelope");
103
+ }
104
+ return {
105
+ deliveryKey: b.delivery_key,
106
+ matchKey: b.match_key,
107
+ status: b.status === "reverted" ? "reverted" : "confirmed",
108
+ signalId: String(b.signal_id ?? ""),
109
+ subId: Number(b.sub_id ?? 0),
110
+ watcherId: Number(b.watcher_id ?? 0),
111
+ market: {
112
+ base: String(b.market?.base ?? ""),
113
+ quote: String(b.market?.quote ?? ""),
114
+ pool: String(b.market?.pool ?? ""),
115
+ },
116
+ event: {
117
+ actor: String(ev.actor ?? ""),
118
+ kind: String(ev.kind ?? ""),
119
+ ts: Number(ev.ts ?? 0),
120
+ amountUsdMicros: numOrNull(ev.amount_usd_micros),
121
+ tokenUsdMicros: numOrNull(ev.token_usd_micros),
122
+ confidence: ev.confidence ?? "none",
123
+ deviationBps: numOrNull(ev.deviation_bps),
124
+ eventId: {
125
+ blockHash: String(eid.block_hash ?? ""),
126
+ txHash: String(eid.tx_hash ?? ""),
127
+ logIndex: Number(eid.log_index ?? 0),
128
+ },
129
+ },
130
+ };
131
+ }
132
+ function numOrNull(v) {
133
+ return typeof v === "number" ? v : null;
134
+ }
135
+ /** Best-effort run-log report (mirrors the keeper wrapper). Never throws. */
136
+ async function reportRun(env, startedAt, ok, error) {
137
+ const url = env.LIVO_RUN_URL;
138
+ const token = env.LIVO_RUN_TOKEN;
139
+ if (!url || !token)
140
+ return;
141
+ await fetch(url, {
142
+ method: "POST",
143
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
144
+ body: JSON.stringify({
145
+ projectId: env.LIVO_PROJECT_ID,
146
+ ownerKind: "watcher",
147
+ ownerName: env.LIVO_WATCHER_NAME,
148
+ trigger: "match",
149
+ startedAt,
150
+ durationMs: Date.now() - startedAt,
151
+ ok,
152
+ error,
153
+ }),
154
+ }).catch(() => undefined);
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livo-build/runtime",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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",
@@ -35,6 +35,7 @@
35
35
  "clean": "rm -rf dist"
36
36
  },
37
37
  "dependencies": {
38
+ "@nktkas/hyperliquid": "^0.33.0",
38
39
  "@noble/curves": "^1.9.7",
39
40
  "@noble/hashes": "^1.8.0"
40
41
  },