@livo-build/runtime 0.2.5 → 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/gate.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { Chain } from "./chain.js";
2
+ export declare function tokenBalanceOf(chain: Chain, token: string, owner: string): Promise<bigint>;
3
+ export interface TokenGate {
4
+ /** ERC-20 / ERC-721 token address. */
5
+ token: string;
6
+ /** Minimum balance (base units for ERC-20; token count for ERC-721). */
7
+ minBalance: bigint;
8
+ }
9
+ /** Whether an owner currently meets a token gate. */
10
+ export declare function meetsGate(chain: Chain, owner: string, gate: TokenGate): Promise<boolean>;
package/dist/gate.js ADDED
@@ -0,0 +1,18 @@
1
+ // Token-gating helpers — does a wallet hold enough of a token to enter / stay in a
2
+ // gated room? Works for ERC-20 (balance) and ERC-721 (count) since both expose
3
+ // balanceOf(address). Read-only — a bot/keeper Chain needs only an RPC_URL.
4
+ // The balanceOf(address)→uint256 of a token for an owner. Returns 0n on a failed
5
+ // read (treat as "doesn't hold") rather than throwing the whole keeper sweep.
6
+ export async function tokenBalanceOf(chain, token, owner) {
7
+ try {
8
+ const r = await chain.call(token, "balanceOf(address)(uint256)", [owner]);
9
+ return BigInt(r);
10
+ }
11
+ catch {
12
+ return 0n;
13
+ }
14
+ }
15
+ /** Whether an owner currently meets a token gate. */
16
+ export async function meetsGate(chain, owner, gate) {
17
+ return (await tokenBalanceOf(chain, gate.token, owner)) >= gate.minBalance;
18
+ }
package/dist/index.d.ts CHANGED
@@ -14,7 +14,13 @@ export type { WalletOptions, WalletAccount, WalletSendOptions } from "./wallet.j
14
14
  export { Indexer, indexer, indexerEnvKey, GraphQLError } from "./indexer.js";
15
15
  export type { IndexerOptions } from "./indexer.js";
16
16
  export { Telegram } from "./telegram.js";
17
- export type { TelegramOptions, BotMessage, SendMessageOptions } from "./telegram.js";
17
+ export type { TelegramOptions, BotMessage, SendMessageOptions, ChatJoinRequest } from "./telegram.js";
18
+ export { verifyTelegramInitData, verifyTelegramLoginWidget, parseStartPayload } from "./telegramAuth.js";
19
+ export type { TelegramUser, VerifyOptions } from "./telegramAuth.js";
20
+ export { TelegramLinks, TELEGRAM_LINKS_TABLE } from "./telegramLinks.js";
21
+ export type { TelegramLink } from "./telegramLinks.js";
22
+ export { tokenBalanceOf, meetsGate } from "./gate.js";
23
+ export type { TokenGate } from "./gate.js";
18
24
  export { Hyperliquid } from "./hyperliquid.js";
19
25
  export type { HyperliquidOptions, PlaceOrderOptions, MarketOrderOptions, Tif, CandleInterval, CandlesOptions, AssetContext, AccountBalance, } from "./hyperliquid.js";
20
26
  export { Polymarket } from "./polymarket.js";
package/dist/index.js CHANGED
@@ -23,6 +23,9 @@ export { Wallet, UserWallet } from "./wallet.js";
23
23
  export { Indexer, indexer, indexerEnvKey, GraphQLError } from "./indexer.js";
24
24
  // Telegram — webhook verification + reply plumbing for bots.
25
25
  export { Telegram } from "./telegram.js";
26
+ export { verifyTelegramInitData, verifyTelegramLoginWidget, parseStartPayload } from "./telegramAuth.js";
27
+ export { TelegramLinks, TELEGRAM_LINKS_TABLE } from "./telegramLinks.js";
28
+ export { tokenBalanceOf, meetsGate } from "./gate.js";
26
29
  // Hyperliquid — perps/spot data + trading (wraps @nktkas/hyperliquid; signs with
27
30
  // the runtime's EIP-712 signer, no viem/ethers in the bundle).
28
31
  export { Hyperliquid } from "./hyperliquid.js";
@@ -38,6 +38,15 @@ export interface PolymarketOptions {
38
38
  * reads always work).
39
39
  */
40
40
  allowLegacyV1?: boolean;
41
+ /**
42
+ * Polymarket Builder/Relayer API key that authorizes V2 deposit-wallet deployment
43
+ * + order placement. On Livo this is a PLATFORM credential (one Livo-held builder
44
+ * key serving every project), injected as env.POLYMARKET_BUILDER_API_KEY — projects
45
+ * never set it. Default: env.POLYMARKET_BUILDER_API_KEY. (V2 order placement via
46
+ * @polymarket/client is not wired yet even when present — see
47
+ * docs/POLYMARKET-V2-IMPLEMENTATION.md.)
48
+ */
49
+ builderApiKey?: string;
41
50
  /** Signing key. Default: env.POLYMARKET_PRIVATE_KEY, then env.RELAYER_PRIVATE_KEY. */
42
51
  privateKey?: string;
43
52
  /** Env var name to read the key from. Default: "POLYMARKET_PRIVATE_KEY". */
@@ -85,6 +94,7 @@ export declare class Polymarket {
85
94
  private readonly host;
86
95
  private readonly privateKey?;
87
96
  private readonly legacyV1;
97
+ private readonly builderApiKey?;
88
98
  private readonly contracts;
89
99
  private creds?;
90
100
  constructor(env: MinimalEnv | undefined, options?: PolymarketOptions);
@@ -122,6 +132,8 @@ export declare class Polymarket {
122
132
  positions(user?: string): Promise<unknown>;
123
133
  /** Ensure API credentials exist (derive existing, else create). Cached on the instance. */
124
134
  ensureCreds(): Promise<PolymarketCreds>;
135
+ /** True once Livo's platform Polymarket builder key is configured (env.POLYMARKET_BUILDER_API_KEY). */
136
+ get builderConfigured(): boolean;
125
137
  private assertTradingEnabled;
126
138
  private deriveOrCreateApiKey;
127
139
  /** Build, sign, and submit an order. Returns the CLOB response JSON. */
@@ -171,12 +171,16 @@ export class Polymarket {
171
171
  host;
172
172
  privateKey;
173
173
  legacyV1;
174
+ builderApiKey;
174
175
  contracts;
175
176
  creds;
176
177
  constructor(env, options = {}) {
177
178
  this.chainId = options.chainId ?? Number(env?.POLYMARKET_CHAIN_ID ?? 137);
178
179
  this.host = options.host ?? env?.POLYMARKET_CLOB_HOST ?? CLOB_HOST;
179
180
  this.legacyV1 = options.allowLegacyV1 ?? (env?.POLYMARKET_ALLOW_LEGACY_V1 === "1" || env?.POLYMARKET_ALLOW_LEGACY_V1 === "true");
181
+ // Platform-injected Livo builder credential (one key for all projects). Read-only here;
182
+ // V2 order placement via @polymarket/client isn't wired yet, so this is detection only.
183
+ this.builderApiKey = options.builderApiKey ?? env?.POLYMARKET_BUILDER_API_KEY;
180
184
  this.contracts = this.legacyV1 ? CONTRACTS_V1 : CONTRACTS_V2;
181
185
  const keySecret = options.privateKeySecret ?? "POLYMARKET_PRIVATE_KEY";
182
186
  this.privateKey =
@@ -306,15 +310,24 @@ export class Polymarket {
306
310
  // All trading + L2-auth methods route through ensureCreds, so this one guard covers
307
311
  // them. Data reads never call it. V2 order signing isn't correctly implemented yet
308
312
  // (see the header note) — gate it rather than submit invalid orders.
313
+ /** True once Livo's platform Polymarket builder key is configured (env.POLYMARKET_BUILDER_API_KEY). */
314
+ get builderConfigured() {
315
+ return Boolean(this.builderApiKey);
316
+ }
309
317
  assertTradingEnabled() {
310
318
  if (this.legacyV1)
311
319
  return; // deprecated V1 path (testnet/legacy)
312
- throw new Error("Polymarket V2 trading isn't available through this helper. Verified live: V2 (Apr 2026) REQUIRES " +
313
- "the deposit-wallet flow (a plain EOA maker is rejected: 'use the deposit wallet flow'), and deploying " +
314
- "a deposit wallet needs a Polymarket BUILDER or RELAYER API key — a credential Polymarket issues to " +
315
- "builders, not something derivable here. The path is to wrap the official @polymarket/client SDK with " +
316
- "that key (see docs/POLYMARKET-V2-IMPLEMENTATION.md). Market-DATA reads work without any of this. " +
317
- "{ allowLegacyV1: true } enables the deprecated V1 path on testnet only.");
320
+ // V2 trading needs BOTH (1) Livo's platform builder key and (2) the @polymarket/client
321
+ // wrap which isn't wired yet. Message reflects which prerequisite is missing.
322
+ if (this.builderConfigured) {
323
+ throw new Error("Polymarket builder key is configured, but V2 order placement via @polymarket/client isn't wired up in " +
324
+ "this runtime yet (deposit-wallet deploy + EIP-1271 signing) — tracked in docs/POLYMARKET-V2-IMPLEMENTATION.md. " +
325
+ "Market-DATA reads work. { allowLegacyV1: true } enables the deprecated V1 path on testnet.");
326
+ }
327
+ throw new Error("Polymarket V2 trading needs Livo's platform builder key (env.POLYMARKET_BUILDER_API_KEY), which isn't " +
328
+ "configured. V2 (Apr 2026) requires the deposit-wallet flow — a plain EOA maker is rejected — and deploying " +
329
+ "a deposit wallet needs a Polymarket Builder/Relayer credential (one Livo-held key serves all projects; see " +
330
+ "docs/POLYMARKET-V2-IMPLEMENTATION.md). Market-DATA reads work without it. { allowLegacyV1: true } = V1 testnet.");
318
331
  }
319
332
  async deriveOrCreateApiKey() {
320
333
  // Try derive (deterministic) first; fall back to create.
@@ -68,6 +68,16 @@ describe("Polymarket V2 trading gate", () => {
68
68
  expect(new Polymarket({}).collateralToken).toBe("0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB");
69
69
  expect(new Polymarket({}, { allowLegacyV1: true }).collateralToken).toBeUndefined();
70
70
  });
71
+ it("detects the platform builder key and adjusts the gate message", async () => {
72
+ // No key configured → message points at the missing platform builder key.
73
+ const noKey = new Polymarket({ POLYMARKET_PRIVATE_KEY: PK });
74
+ expect(noKey.builderConfigured).toBe(false);
75
+ await expect(noKey.placeOrder({ tokenId: "1", side: "BUY", price: 0.5, size: 2, tickSize: "0.01", negRisk: false })).rejects.toThrow(/platform builder key/);
76
+ // Platform key injected (env) → detected; gate now points at the un-wired SDK path.
77
+ const withKey = new Polymarket({ POLYMARKET_PRIVATE_KEY: PK, POLYMARKET_BUILDER_API_KEY: "blder_test" });
78
+ expect(withKey.builderConfigured).toBe(true);
79
+ await expect(withKey.placeOrder({ tokenId: "1", side: "BUY", price: 0.5, size: 2, tickSize: "0.01", negRisk: false })).rejects.toThrow(/isn't wired up/);
80
+ });
71
81
  });
72
82
  describe("Polymarket data helpers", () => {
73
83
  it("resolveMarket maps Gamma clobTokenIds + outcomes to tradable tokens", async () => {
@@ -67,5 +67,36 @@ export declare class Telegram {
67
67
  command: string;
68
68
  description: string;
69
69
  }>): Promise<void>;
70
+ private api;
71
+ /** Parse a `chat_join_request` update (a user asking to join a join-request chat). */
72
+ joinRequest(update: Record<string, unknown>): ChatJoinRequest | null;
73
+ /** Admit a pending join request. */
74
+ approveJoinRequest(chatId: number | string, userId: number): Promise<unknown>;
75
+ /** Reject a pending join request. */
76
+ declineJoinRequest(chatId: number | string, userId: number): Promise<unknown>;
77
+ /** Remove (ban) a member — the "boot" when they no longer qualify. */
78
+ banMember(chatId: number | string, userId: number, opts?: {
79
+ untilDate?: number;
80
+ revokeMessages?: boolean;
81
+ }): Promise<unknown>;
82
+ /** Lift a ban so a re-qualified user can rejoin (does NOT add them back). */
83
+ unbanMember(chatId: number | string, userId: number, opts?: {
84
+ onlyIfBanned?: boolean;
85
+ }): Promise<unknown>;
86
+ /** Create an invite link. With createsJoinRequest, joins need bot approval (the gate). */
87
+ createInviteLink(chatId: number | string, opts?: {
88
+ name?: string;
89
+ createsJoinRequest?: boolean;
90
+ expireDate?: number;
91
+ }): Promise<string>;
92
+ /** A member's status in a chat ("member" | "left" | "kicked" | "administrator" | "creator" | "restricted"). */
93
+ memberStatus(chatId: number | string, userId: number): Promise<string | null>;
94
+ }
95
+ export interface ChatJoinRequest {
96
+ chatId: number;
97
+ userId: number;
98
+ username?: string;
99
+ from: Record<string, unknown>;
100
+ raw: Record<string, unknown>;
70
101
  }
71
102
  export {};
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livo-build/runtime",
3
- "version": "0.2.5",
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": ["dist"],
20
- "engines": { "node": ">=18" },
21
- "keywords": ["livo", "web3", "keeper", "eip1559", "evm"],
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
  },