@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 +10 -0
- package/dist/gate.js +18 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +3 -0
- package/dist/polymarket.d.ts +12 -0
- package/dist/polymarket.js +19 -6
- package/dist/polymarket.test.js +10 -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/package.json +14 -4
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";
|
package/dist/polymarket.d.ts
CHANGED
|
@@ -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. */
|
package/dist/polymarket.js
CHANGED
|
@@ -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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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.
|
package/dist/polymarket.test.js
CHANGED
|
@@ -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 () => {
|
package/dist/telegram.d.ts
CHANGED
|
@@ -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.
|
|
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
|
},
|