@livo-build/runtime 0.2.4 → 0.2.6
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/eip712.d.ts +45 -0
- package/dist/eip712.js +160 -0
- package/dist/eip712.test.d.ts +1 -0
- package/dist/eip712.test.js +106 -0
- package/dist/gate.d.ts +10 -0
- package/dist/gate.js +18 -0
- package/dist/hyperliquid.d.ts +360 -0
- package/dist/hyperliquid.js +350 -0
- package/dist/hyperliquid.test.d.ts +1 -0
- package/dist/hyperliquid.test.js +31 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +14 -0
- package/dist/polymarket.d.ts +158 -0
- package/dist/polymarket.js +508 -0
- package/dist/polymarket.test.d.ts +1 -0
- package/dist/polymarket.test.js +121 -0
- package/dist/telegram.d.ts +31 -0
- package/dist/telegram.js +73 -0
- package/dist/telegramAuth.d.ts +16 -0
- package/dist/telegramAuth.js +108 -0
- package/dist/telegramAuth.test.d.ts +1 -0
- package/dist/telegramAuth.test.js +68 -0
- package/dist/telegramLinks.d.ts +27 -0
- package/dist/telegramLinks.js +78 -0
- package/dist/watcher.d.ts +83 -0
- package/dist/watcher.js +155 -0
- package/package.json +15 -4
package/dist/telegram.js
CHANGED
|
@@ -132,4 +132,77 @@ export class Telegram {
|
|
|
132
132
|
body: JSON.stringify({ commands }),
|
|
133
133
|
});
|
|
134
134
|
}
|
|
135
|
+
// ---- Group / membership management (token-gated rooms) --------------------
|
|
136
|
+
// Generic Bot API call: POST JSON, return result, throw on Telegram error.
|
|
137
|
+
async api(method, body) {
|
|
138
|
+
if (!this.token)
|
|
139
|
+
throw new Error("Telegram: no bot token (set_secret TELEGRAM_BOT_TOKEN=<token>).");
|
|
140
|
+
const res = await fetch(`${API}${this.token}/${method}`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "content-type": "application/json" },
|
|
143
|
+
body: JSON.stringify(body),
|
|
144
|
+
});
|
|
145
|
+
const j = (await res.json().catch(() => null));
|
|
146
|
+
if (!res.ok || !j?.ok)
|
|
147
|
+
throw new Error(`Telegram ${method} failed: ${j?.description ?? "HTTP " + res.status}`);
|
|
148
|
+
return j.result;
|
|
149
|
+
}
|
|
150
|
+
/** Parse a `chat_join_request` update (a user asking to join a join-request chat). */
|
|
151
|
+
joinRequest(update) {
|
|
152
|
+
const r = update.chat_join_request;
|
|
153
|
+
if (!r)
|
|
154
|
+
return null;
|
|
155
|
+
const from = r.from;
|
|
156
|
+
const chat = r.chat;
|
|
157
|
+
if (!from || !chat)
|
|
158
|
+
return null;
|
|
159
|
+
return {
|
|
160
|
+
chatId: chat.id,
|
|
161
|
+
userId: from.id,
|
|
162
|
+
username: from.username,
|
|
163
|
+
from,
|
|
164
|
+
raw: r,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** Admit a pending join request. */
|
|
168
|
+
approveJoinRequest(chatId, userId) {
|
|
169
|
+
return this.api("approveChatJoinRequest", { chat_id: chatId, user_id: userId });
|
|
170
|
+
}
|
|
171
|
+
/** Reject a pending join request. */
|
|
172
|
+
declineJoinRequest(chatId, userId) {
|
|
173
|
+
return this.api("declineChatJoinRequest", { chat_id: chatId, user_id: userId });
|
|
174
|
+
}
|
|
175
|
+
/** Remove (ban) a member — the "boot" when they no longer qualify. */
|
|
176
|
+
banMember(chatId, userId, opts = {}) {
|
|
177
|
+
return this.api("banChatMember", {
|
|
178
|
+
chat_id: chatId,
|
|
179
|
+
user_id: userId,
|
|
180
|
+
...(opts.untilDate ? { until_date: opts.untilDate } : {}),
|
|
181
|
+
...(opts.revokeMessages ? { revoke_messages: true } : {}),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/** Lift a ban so a re-qualified user can rejoin (does NOT add them back). */
|
|
185
|
+
unbanMember(chatId, userId, opts = { onlyIfBanned: true }) {
|
|
186
|
+
return this.api("unbanChatMember", { chat_id: chatId, user_id: userId, only_if_banned: opts.onlyIfBanned !== false });
|
|
187
|
+
}
|
|
188
|
+
/** Create an invite link. With createsJoinRequest, joins need bot approval (the gate). */
|
|
189
|
+
async createInviteLink(chatId, opts = {}) {
|
|
190
|
+
const result = (await this.api("createChatInviteLink", {
|
|
191
|
+
chat_id: chatId,
|
|
192
|
+
...(opts.name ? { name: opts.name } : {}),
|
|
193
|
+
...(opts.createsJoinRequest ? { creates_join_request: true } : {}),
|
|
194
|
+
...(opts.expireDate ? { expire_date: opts.expireDate } : {}),
|
|
195
|
+
}));
|
|
196
|
+
return result.invite_link ?? "";
|
|
197
|
+
}
|
|
198
|
+
/** A member's status in a chat ("member" | "left" | "kicked" | "administrator" | "creator" | "restricted"). */
|
|
199
|
+
async memberStatus(chatId, userId) {
|
|
200
|
+
try {
|
|
201
|
+
const result = (await this.api("getChatMember", { chat_id: chatId, user_id: userId }));
|
|
202
|
+
return result.status ?? null;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
135
208
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface TelegramUser {
|
|
2
|
+
id: number;
|
|
3
|
+
username?: string;
|
|
4
|
+
firstName?: string;
|
|
5
|
+
lastName?: string;
|
|
6
|
+
languageCode?: string;
|
|
7
|
+
isPremium?: boolean;
|
|
8
|
+
photoUrl?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface VerifyOptions {
|
|
11
|
+
/** Reject if auth_date is older than this many seconds (0 = no check). Default 86400. */
|
|
12
|
+
maxAgeSeconds?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function verifyTelegramInitData(initData: string, botToken: string, options?: VerifyOptions): Promise<TelegramUser | null>;
|
|
15
|
+
export declare function verifyTelegramLoginWidget(data: Record<string, string | number | undefined>, botToken: string, options?: VerifyOptions): Promise<TelegramUser | null>;
|
|
16
|
+
export declare function parseStartPayload(text: string): string | null;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Telegram identity verification — the security core of linking a Telegram user to
|
|
2
|
+
// a wallet. Runs server-side (bot token never leaves the server). Two sources:
|
|
3
|
+
// • Mini App initData — verifyTelegramInitData (HMAC keyed by "WebAppData")
|
|
4
|
+
// • Login Widget data — verifyTelegramLoginWidget (HMAC keyed by SHA256(token))
|
|
5
|
+
// Both follow Telegram's documented algorithm exactly: build a data_check_string of
|
|
6
|
+
// the fields (sorted, key=value, "\n"-joined, EXCLUDING `hash`), HMAC-SHA256 it, and
|
|
7
|
+
// constant-time compare to the provided `hash`. Web Crypto only (Worker-safe).
|
|
8
|
+
// Bare lowercase hex (NO "0x" prefix) — Telegram's `hash` is bare hex, so we must
|
|
9
|
+
// not use the chain-style bytesToHex (which prefixes "0x").
|
|
10
|
+
function toHex(bytes) {
|
|
11
|
+
let out = "";
|
|
12
|
+
for (let i = 0; i < bytes.length; i++)
|
|
13
|
+
out += bytes[i].toString(16).padStart(2, "0");
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
const enc = (s) => new TextEncoder().encode(s);
|
|
17
|
+
async function hmacSha256(keyBytes, msg) {
|
|
18
|
+
const key = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
19
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", key, msg));
|
|
20
|
+
}
|
|
21
|
+
async function sha256(bytes) {
|
|
22
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
23
|
+
}
|
|
24
|
+
// Constant-time-ish hex compare (avoids leaking the match position).
|
|
25
|
+
function safeEqualHex(a, b) {
|
|
26
|
+
if (a.length !== b.length)
|
|
27
|
+
return false;
|
|
28
|
+
let diff = 0;
|
|
29
|
+
for (let i = 0; i < a.length; i++)
|
|
30
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
31
|
+
return diff === 0;
|
|
32
|
+
}
|
|
33
|
+
function freshEnough(authDate, maxAgeSeconds) {
|
|
34
|
+
if (maxAgeSeconds <= 0)
|
|
35
|
+
return true;
|
|
36
|
+
if (!authDate)
|
|
37
|
+
return false;
|
|
38
|
+
return Math.floor(Date.now() / 1000) - authDate <= maxAgeSeconds;
|
|
39
|
+
}
|
|
40
|
+
function userFromRaw(u) {
|
|
41
|
+
return {
|
|
42
|
+
id: Number(u.id),
|
|
43
|
+
username: u.username,
|
|
44
|
+
firstName: u.first_name,
|
|
45
|
+
lastName: u.last_name,
|
|
46
|
+
languageCode: u.language_code,
|
|
47
|
+
isPremium: u.is_premium,
|
|
48
|
+
photoUrl: u.photo_url,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Verify Telegram Mini App `initData` (the raw query string from
|
|
52
|
+
// window.Telegram.WebApp.initData). Returns the verified user, or null.
|
|
53
|
+
export async function verifyTelegramInitData(initData, botToken, options = {}) {
|
|
54
|
+
if (!initData || !botToken)
|
|
55
|
+
return null;
|
|
56
|
+
const params = new URLSearchParams(initData);
|
|
57
|
+
const hash = params.get("hash");
|
|
58
|
+
if (!hash)
|
|
59
|
+
return null;
|
|
60
|
+
params.delete("hash");
|
|
61
|
+
const dataCheckString = [...params.entries()]
|
|
62
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
63
|
+
.sort()
|
|
64
|
+
.join("\n");
|
|
65
|
+
// secret_key = HMAC_SHA256(key="WebAppData", message=bot_token)
|
|
66
|
+
const secretKey = await hmacSha256(enc("WebAppData"), enc(botToken));
|
|
67
|
+
const calc = toHex(await hmacSha256(secretKey, enc(dataCheckString)));
|
|
68
|
+
if (!safeEqualHex(calc, hash))
|
|
69
|
+
return null;
|
|
70
|
+
if (!freshEnough(Number(params.get("auth_date") ?? 0), options.maxAgeSeconds ?? 86400))
|
|
71
|
+
return null;
|
|
72
|
+
const userJson = params.get("user");
|
|
73
|
+
if (!userJson)
|
|
74
|
+
return null;
|
|
75
|
+
try {
|
|
76
|
+
return userFromRaw(JSON.parse(userJson));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Verify Telegram Login Widget data (the object the widget hands your page, with a
|
|
83
|
+
// `hash`). Returns the verified user, or null.
|
|
84
|
+
export async function verifyTelegramLoginWidget(data, botToken, options = {}) {
|
|
85
|
+
const hash = data.hash;
|
|
86
|
+
if (!hash || !botToken)
|
|
87
|
+
return null;
|
|
88
|
+
const dataCheckString = Object.keys(data)
|
|
89
|
+
.filter((k) => k !== "hash" && data[k] !== undefined)
|
|
90
|
+
.sort()
|
|
91
|
+
.map((k) => `${k}=${data[k]}`)
|
|
92
|
+
.join("\n");
|
|
93
|
+
// secret_key = SHA256(bot_token) (NOTE: plain SHA-256 here, not HMAC).
|
|
94
|
+
const secretKey = await sha256(enc(botToken));
|
|
95
|
+
const calc = toHex(await hmacSha256(secretKey, enc(dataCheckString)));
|
|
96
|
+
if (!safeEqualHex(calc, String(hash)))
|
|
97
|
+
return null;
|
|
98
|
+
if (!freshEnough(Number(data.auth_date ?? 0), options.maxAgeSeconds ?? 86400))
|
|
99
|
+
return null;
|
|
100
|
+
return userFromRaw(data);
|
|
101
|
+
}
|
|
102
|
+
// Pull the payload from a `/start <payload>` deep-link command (the non-Mini-App
|
|
103
|
+
// linking flow). Returns the trimmed payload, or null.
|
|
104
|
+
export function parseStartPayload(text) {
|
|
105
|
+
const m = /^\/start(?:@\w+)?(?:\s+(.+))?$/.exec(text.trim());
|
|
106
|
+
const payload = m?.[1]?.trim();
|
|
107
|
+
return payload ? payload : null;
|
|
108
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createHmac, createHash } from "node:crypto";
|
|
3
|
+
import { verifyTelegramInitData, verifyTelegramLoginWidget, parseStartPayload } from "./telegramAuth.js";
|
|
4
|
+
const BOT_TOKEN = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11";
|
|
5
|
+
const USER = { id: 42, username: "alice", first_name: "Alice", language_code: "en" };
|
|
6
|
+
// Build a valid Mini App initData the way Telegram does — using node:crypto, an
|
|
7
|
+
// INDEPENDENT implementation from the verifier's Web Crypto. Agreement = correct.
|
|
8
|
+
function makeInitData(botToken, authDate, user = USER) {
|
|
9
|
+
const params = new URLSearchParams();
|
|
10
|
+
params.set("user", JSON.stringify(user));
|
|
11
|
+
params.set("auth_date", String(authDate));
|
|
12
|
+
params.set("query_id", "AAH-test");
|
|
13
|
+
const dcs = [...params.entries()].map(([k, v]) => `${k}=${v}`).sort().join("\n");
|
|
14
|
+
const secret = createHmac("sha256", "WebAppData").update(botToken).digest();
|
|
15
|
+
const hash = createHmac("sha256", secret).update(dcs).digest("hex");
|
|
16
|
+
params.set("hash", hash);
|
|
17
|
+
return params.toString();
|
|
18
|
+
}
|
|
19
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
20
|
+
describe("verifyTelegramInitData", () => {
|
|
21
|
+
it("accepts a valid initData and returns the user", async () => {
|
|
22
|
+
const u = await verifyTelegramInitData(makeInitData(BOT_TOKEN, now()), BOT_TOKEN);
|
|
23
|
+
expect(u).not.toBeNull();
|
|
24
|
+
expect(u?.id).toBe(42);
|
|
25
|
+
expect(u?.username).toBe("alice");
|
|
26
|
+
expect(u?.firstName).toBe("Alice");
|
|
27
|
+
});
|
|
28
|
+
it("rejects a tampered hash", async () => {
|
|
29
|
+
const good = makeInitData(BOT_TOKEN, now());
|
|
30
|
+
const tampered = good.replace(/hash=([0-9a-f])/, (_, c) => `hash=${c === "0" ? "1" : "0"}`);
|
|
31
|
+
expect(await verifyTelegramInitData(tampered, BOT_TOKEN)).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
it("rejects the wrong bot token", async () => {
|
|
34
|
+
expect(await verifyTelegramInitData(makeInitData(BOT_TOKEN, now()), "999:WRONG")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
it("rejects stale auth_date", async () => {
|
|
37
|
+
const old = makeInitData(BOT_TOKEN, now() - 100_000);
|
|
38
|
+
expect(await verifyTelegramInitData(old, BOT_TOKEN, { maxAgeSeconds: 3600 })).toBeNull();
|
|
39
|
+
// ...but accepts it when the age check is disabled.
|
|
40
|
+
expect(await verifyTelegramInitData(old, BOT_TOKEN, { maxAgeSeconds: 0 })).not.toBeNull();
|
|
41
|
+
});
|
|
42
|
+
it("rejects empty / hashless input", async () => {
|
|
43
|
+
expect(await verifyTelegramInitData("", BOT_TOKEN)).toBeNull();
|
|
44
|
+
expect(await verifyTelegramInitData("user=%7B%7D&auth_date=1", BOT_TOKEN)).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe("verifyTelegramLoginWidget", () => {
|
|
48
|
+
it("accepts valid widget data (SHA256(token) secret)", async () => {
|
|
49
|
+
const data = { id: 42, username: "alice", auth_date: now() };
|
|
50
|
+
const dcs = Object.keys(data).sort().map((k) => `${k}=${data[k]}`).join("\n");
|
|
51
|
+
const secret = createHash("sha256").update(BOT_TOKEN).digest();
|
|
52
|
+
data.hash = createHmac("sha256", secret).update(dcs).digest("hex");
|
|
53
|
+
const u = await verifyTelegramLoginWidget(data, BOT_TOKEN);
|
|
54
|
+
expect(u?.id).toBe(42);
|
|
55
|
+
expect(u?.username).toBe("alice");
|
|
56
|
+
});
|
|
57
|
+
it("rejects a bad hash", async () => {
|
|
58
|
+
expect(await verifyTelegramLoginWidget({ id: 1, auth_date: now(), hash: "deadbeef" }, BOT_TOKEN)).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe("parseStartPayload", () => {
|
|
62
|
+
it("extracts the payload from /start", () => {
|
|
63
|
+
expect(parseStartPayload("/start link_abc123")).toBe("link_abc123");
|
|
64
|
+
expect(parseStartPayload("/start@MyBot tok")).toBe("tok");
|
|
65
|
+
expect(parseStartPayload("/start")).toBeNull();
|
|
66
|
+
expect(parseStartPayload("hello")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { D1Like } from "./store.js";
|
|
2
|
+
export declare const TELEGRAM_LINKS_TABLE = "telegram_links";
|
|
3
|
+
export interface TelegramLink {
|
|
4
|
+
telegramId: string;
|
|
5
|
+
address: string;
|
|
6
|
+
username?: string;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
}
|
|
9
|
+
export declare class TelegramLinks {
|
|
10
|
+
private readonly db;
|
|
11
|
+
private readonly table;
|
|
12
|
+
private ready;
|
|
13
|
+
constructor(db: D1Like, table?: string);
|
|
14
|
+
private init;
|
|
15
|
+
/** Create/replace the link for a Telegram user (one wallet per Telegram id). */
|
|
16
|
+
link(telegramId: string | number, address: string, username?: string): Promise<void>;
|
|
17
|
+
/** Remove the link for a Telegram user. */
|
|
18
|
+
unlink(telegramId: string | number): Promise<void>;
|
|
19
|
+
/** The wallet address linked to a Telegram user, or null. */
|
|
20
|
+
walletFor(telegramId: string | number): Promise<string | null>;
|
|
21
|
+
/** The Telegram link for a wallet address, or null. */
|
|
22
|
+
telegramFor(address: string): Promise<TelegramLink | null>;
|
|
23
|
+
/** Whether a Telegram user has a linked wallet. */
|
|
24
|
+
isLinked(telegramId: string | number): Promise<boolean>;
|
|
25
|
+
/** Every link — for a keeper that re-checks balances and boots ineligible users. */
|
|
26
|
+
all(limit?: number): Promise<TelegramLink[]>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// TelegramLinks — the shared binding between a Telegram user and a wallet address,
|
|
2
|
+
// stored in the project's D1 (env.DB, which is bound into BOTH the api Worker and
|
|
3
|
+
// the bot). Write it from the api after verifying both proofs; read it from the bot
|
|
4
|
+
// to answer "is this user's wallet connected?".
|
|
5
|
+
export const TELEGRAM_LINKS_TABLE = "telegram_links";
|
|
6
|
+
export class TelegramLinks {
|
|
7
|
+
db;
|
|
8
|
+
table;
|
|
9
|
+
ready = null;
|
|
10
|
+
constructor(db, table = TELEGRAM_LINKS_TABLE) {
|
|
11
|
+
this.db = db;
|
|
12
|
+
this.table = table;
|
|
13
|
+
if (!db)
|
|
14
|
+
throw new Error("TelegramLinks: no D1 binding — pass env.DB (is the project's D1 bound?)");
|
|
15
|
+
}
|
|
16
|
+
async init() {
|
|
17
|
+
if (!this.ready) {
|
|
18
|
+
this.ready = (async () => {
|
|
19
|
+
if (this.db.exec) {
|
|
20
|
+
await this.db.exec(`CREATE TABLE IF NOT EXISTS ${this.table} (telegram_id TEXT PRIMARY KEY, address TEXT NOT NULL, username TEXT, created_at INTEGER); CREATE INDEX IF NOT EXISTS ${this.table}_address ON ${this.table} (address);`);
|
|
21
|
+
}
|
|
22
|
+
})();
|
|
23
|
+
}
|
|
24
|
+
return this.ready;
|
|
25
|
+
}
|
|
26
|
+
/** Create/replace the link for a Telegram user (one wallet per Telegram id). */
|
|
27
|
+
async link(telegramId, address, username) {
|
|
28
|
+
await this.init();
|
|
29
|
+
await this.db
|
|
30
|
+
.prepare(`INSERT INTO ${this.table} (telegram_id, address, username, created_at) VALUES (?, ?, ?, ?)
|
|
31
|
+
ON CONFLICT(telegram_id) DO UPDATE SET address = excluded.address, username = excluded.username, created_at = excluded.created_at`)
|
|
32
|
+
.bind(String(telegramId), address.toLowerCase(), username ?? null, Date.now())
|
|
33
|
+
.run();
|
|
34
|
+
}
|
|
35
|
+
/** Remove the link for a Telegram user. */
|
|
36
|
+
async unlink(telegramId) {
|
|
37
|
+
await this.init();
|
|
38
|
+
await this.db.prepare(`DELETE FROM ${this.table} WHERE telegram_id = ?`).bind(String(telegramId)).run();
|
|
39
|
+
}
|
|
40
|
+
/** The wallet address linked to a Telegram user, or null. */
|
|
41
|
+
async walletFor(telegramId) {
|
|
42
|
+
await this.init();
|
|
43
|
+
const row = await this.db
|
|
44
|
+
.prepare(`SELECT address FROM ${this.table} WHERE telegram_id = ?`)
|
|
45
|
+
.bind(String(telegramId))
|
|
46
|
+
.first();
|
|
47
|
+
return row ? row.address : null;
|
|
48
|
+
}
|
|
49
|
+
/** The Telegram link for a wallet address, or null. */
|
|
50
|
+
async telegramFor(address) {
|
|
51
|
+
await this.init();
|
|
52
|
+
const row = await this.db
|
|
53
|
+
.prepare(`SELECT telegram_id, address, username, created_at FROM ${this.table} WHERE address = ?`)
|
|
54
|
+
.bind(address.toLowerCase())
|
|
55
|
+
.first();
|
|
56
|
+
return row
|
|
57
|
+
? { telegramId: row.telegram_id, address: row.address, username: row.username ?? undefined, createdAt: row.created_at }
|
|
58
|
+
: null;
|
|
59
|
+
}
|
|
60
|
+
/** Whether a Telegram user has a linked wallet. */
|
|
61
|
+
async isLinked(telegramId) {
|
|
62
|
+
return (await this.walletFor(telegramId)) !== null;
|
|
63
|
+
}
|
|
64
|
+
/** Every link — for a keeper that re-checks balances and boots ineligible users. */
|
|
65
|
+
async all(limit = 1000) {
|
|
66
|
+
await this.init();
|
|
67
|
+
const res = await this.db
|
|
68
|
+
.prepare(`SELECT telegram_id, address, username, created_at FROM ${this.table} LIMIT ?`)
|
|
69
|
+
.bind(limit)
|
|
70
|
+
.all();
|
|
71
|
+
return (res.results ?? []).map((r) => ({
|
|
72
|
+
telegramId: r.telegram_id,
|
|
73
|
+
address: r.address,
|
|
74
|
+
username: r.username ?? undefined,
|
|
75
|
+
createdAt: r.created_at,
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -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 {};
|
package/dist/watcher.js
ADDED
|
@@ -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
|
+
"version": "0.2.6",
|
|
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",
|
|
@@ -16,9 +16,19 @@
|
|
|
16
16
|
"import": "./dist/contracts.js"
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
|
-
"files": [
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"livo",
|
|
27
|
+
"web3",
|
|
28
|
+
"keeper",
|
|
29
|
+
"eip1559",
|
|
30
|
+
"evm"
|
|
31
|
+
],
|
|
22
32
|
"publishConfig": {
|
|
23
33
|
"access": "public"
|
|
24
34
|
},
|
|
@@ -35,6 +45,7 @@
|
|
|
35
45
|
"clean": "rm -rf dist"
|
|
36
46
|
},
|
|
37
47
|
"dependencies": {
|
|
48
|
+
"@nktkas/hyperliquid": "^0.33.0",
|
|
38
49
|
"@noble/curves": "^1.9.7",
|
|
39
50
|
"@noble/hashes": "^1.8.0"
|
|
40
51
|
},
|