@www.hyperlinks.space/program-kit 1.2.91881 → 7.8.3
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/.eas/workflows/create-development-builds.yml +21 -0
- package/.eas/workflows/create-draft.yml +15 -0
- package/.eas/workflows/deploy-to-production.yml +68 -0
- package/.env.example +19 -0
- package/.gitattributes +48 -0
- package/.gitignore +52 -0
- package/.nvmrc +1 -0
- package/.vercelignore +6 -0
- package/README.md +10 -5
- package/ai/openai.ts +202 -0
- package/ai/transmitter.ts +367 -0
- package/api/{base.ts → _base.ts} +1 -1
- package/api/wallet/_auth.ts +143 -0
- package/api/wallet/register.ts +151 -0
- package/api/wallet/status.ts +89 -0
- package/app/index.tsx +319 -5
- package/assets/images/PreviewImage.png +0 -0
- package/backlogs/medium_term_backlog.md +26 -0
- package/backlogs/short_term_backlog.md +42 -0
- package/database/start.ts +0 -1
- package/database/wallets.ts +266 -0
- package/eslint.config.cjs +10 -0
- package/fullREADME.md +142 -71
- package/index.js +3 -0
- package/npmReadMe.md +10 -5
- package/npmrc.example +1 -0
- package/package.json +7 -27
- package/polyfills/buffer.ts +9 -0
- package/research & docs/auth-and-centralized-encrypted-keys-plan.md +440 -0
- package/research & docs/github-gitlab-bidirectional-mirroring.md +154 -0
- package/research & docs/keys-retrieval-console-scripts.js +131 -0
- package/{docs/security_plan_raw.md → research & docs/security_plan_raw.md } +1 -1
- package/{docs/security_raw.md → research & docs/security_raw.md } +22 -13
- package/research & docs/storage-availability-console-script.js +152 -0
- package/research & docs/storage-lifetime.md +33 -0
- package/research & docs/telegram-raw-keys-cloud-storage-risks.md +31 -0
- package/{docs/wallets_hosting_architecture.md → research & docs/wallets_hosting_architecture.md } +147 -1
- package/scripts/test-api-base.ts +2 -2
- package/services/wallet/tonWallet.ts +73 -0
- package/telegram/post.ts +44 -8
- package/ui/components/GlobalBottomBar.tsx +447 -0
- package/ui/components/GlobalBottomBarWeb.tsx +362 -0
- package/ui/components/GlobalLogoBar.tsx +108 -0
- package/ui/components/GlobalLogoBarFallback.tsx +66 -0
- package/ui/components/GlobalLogoBarWithFallback.tsx +24 -0
- package/ui/components/HyperlinksSpaceLogo.tsx +29 -0
- package/ui/components/Telegram.tsx +677 -0
- package/ui/components/telegramWebApp.ts +359 -0
- package/ui/fonts.ts +12 -0
- package/ui/theme.ts +117 -0
- /package/{docs → research & docs}/ai_and_search_bar_input.md +0 -0
- /package/{docs → research & docs}/ai_bot_messages.md +0 -0
- /package/{docs → research & docs}/blue_bar_tackling.md +0 -0
- /package/{docs → research & docs}/bot_async_streaming.md +0 -0
- /package/{docs → research & docs}/build_and_install.md +0 -0
- /package/{docs → research & docs}/database_messages.md +0 -0
- /package/{docs → research & docs}/fonts.md +0 -0
- /package/{docs → research & docs}/npm-release.md +0 -0
- /package/{docs → research & docs}/releases.md +0 -0
- /package/{docs → research & docs}/releases_github_actions.md +0 -0
- /package/{docs → research & docs}/scalability.md +0 -0
- /package/{docs → research & docs}/timing_raw.md +0 -0
- /package/{docs → research & docs}/tma_logo_bar_jump_investigation.md +0 -0
- /package/{docs → research & docs}/update.md +0 -0
- /package/{docs → research & docs}/wallet_telegram_standalone_multichain_proposal.md +0 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import type { AiMode, AiRequestBase, AiResponseBase, ThreadContext } from "./openai.js";
|
|
2
|
+
import { callOpenAiChat, callOpenAiChatStream } from "./openai.js";
|
|
3
|
+
import {
|
|
4
|
+
getTokenBySymbol,
|
|
5
|
+
normalizeSymbol,
|
|
6
|
+
type TokenSearchResult,
|
|
7
|
+
} from "../blockchain/coffee.js";
|
|
8
|
+
import {
|
|
9
|
+
insertMessage,
|
|
10
|
+
getThreadHistory,
|
|
11
|
+
type Message,
|
|
12
|
+
} from "../database/messages.js";
|
|
13
|
+
|
|
14
|
+
export type AiRequest = AiRequestBase & {
|
|
15
|
+
mode?: AiMode;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type AiResponse = AiResponseBase;
|
|
19
|
+
|
|
20
|
+
const HISTORY_LIMIT = 50;
|
|
21
|
+
|
|
22
|
+
function formatHistoryForInput(history: Message[]): string {
|
|
23
|
+
if (history.length === 0) return "";
|
|
24
|
+
const lines = history.map((m) => {
|
|
25
|
+
const role = m.role === "user" ? "user" : m.role === "assistant" ? "assistant" : "system";
|
|
26
|
+
const content = (m.content ?? "").trim();
|
|
27
|
+
return `${role}: ${content}`;
|
|
28
|
+
});
|
|
29
|
+
return "Previous conversation:\n" + lines.join("\n") + "\n\n";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Claim by insert; return skipped response if another instance won. */
|
|
33
|
+
async function claimUserMessage(
|
|
34
|
+
thread: ThreadContext,
|
|
35
|
+
content: string,
|
|
36
|
+
): Promise<AiResponse | null> {
|
|
37
|
+
const inserted = await insertMessage({
|
|
38
|
+
user_telegram: thread.user_telegram,
|
|
39
|
+
thread_id: thread.thread_id,
|
|
40
|
+
type: thread.type,
|
|
41
|
+
role: "user",
|
|
42
|
+
content,
|
|
43
|
+
telegram_update_id: thread.telegram_update_id ?? undefined,
|
|
44
|
+
});
|
|
45
|
+
if (inserted === null) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
provider: "openai",
|
|
49
|
+
mode: "chat",
|
|
50
|
+
skipped: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function persistAssistantMessage(
|
|
57
|
+
thread: ThreadContext,
|
|
58
|
+
content: string,
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
await insertMessage({
|
|
61
|
+
user_telegram: thread.user_telegram,
|
|
62
|
+
thread_id: thread.thread_id,
|
|
63
|
+
type: thread.type,
|
|
64
|
+
role: "assistant",
|
|
65
|
+
content,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function extractSymbolCandidate(input: string): string | null {
|
|
70
|
+
const raw = input.trim();
|
|
71
|
+
if (!raw) return null;
|
|
72
|
+
|
|
73
|
+
// Simple patterns like "USDT", "$USDT", "USDT on TON".
|
|
74
|
+
const parts = raw.split(/\s+/);
|
|
75
|
+
const first = parts[0]?.replace(/^\$/g, "") ?? "";
|
|
76
|
+
const normalized = normalizeSymbol(first);
|
|
77
|
+
return normalized || null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildTokenFactsBlock(symbol: string, token: any): string {
|
|
81
|
+
const lines: string[] = [];
|
|
82
|
+
|
|
83
|
+
const sym = token?.symbol ?? symbol;
|
|
84
|
+
const name = token?.name ?? null;
|
|
85
|
+
const address = token?.id ?? token?.address ?? null;
|
|
86
|
+
const type = token?.type ?? "token";
|
|
87
|
+
const decimals = token?.decimals ?? token?.metadata?.decimals ?? null;
|
|
88
|
+
const verification =
|
|
89
|
+
token?.verification ?? token?.metadata?.verification ?? null;
|
|
90
|
+
|
|
91
|
+
const market = token?.market_stats ?? {};
|
|
92
|
+
const holders =
|
|
93
|
+
market?.holders_count ?? token?.holders ?? market?.holders ?? null;
|
|
94
|
+
const priceUsd = market?.price_usd ?? null;
|
|
95
|
+
const mcap = market?.mcap ?? market?.fdmc ?? null;
|
|
96
|
+
const volume24h = market?.volume_usd_24h ?? null;
|
|
97
|
+
|
|
98
|
+
lines.push(`Symbol: ${sym}`);
|
|
99
|
+
if (name) {
|
|
100
|
+
lines.push(`Name: ${name}`);
|
|
101
|
+
}
|
|
102
|
+
lines.push(`Type: ${type}`);
|
|
103
|
+
lines.push(`Blockchain: TON`);
|
|
104
|
+
if (address) {
|
|
105
|
+
lines.push(`Address: ${address}`);
|
|
106
|
+
}
|
|
107
|
+
if (decimals != null) {
|
|
108
|
+
lines.push(`Decimals: ${decimals}`);
|
|
109
|
+
}
|
|
110
|
+
if (verification) {
|
|
111
|
+
lines.push(`Verification: ${verification}`);
|
|
112
|
+
}
|
|
113
|
+
if (holders != null) {
|
|
114
|
+
lines.push(`Holders: ${holders}`);
|
|
115
|
+
}
|
|
116
|
+
if (priceUsd != null) {
|
|
117
|
+
lines.push(`Price (USD): ${priceUsd}`);
|
|
118
|
+
}
|
|
119
|
+
if (mcap != null) {
|
|
120
|
+
lines.push(`Market cap (USD): ${mcap}`);
|
|
121
|
+
}
|
|
122
|
+
if (volume24h != null) {
|
|
123
|
+
lines.push(`24h volume (USD): ${volume24h}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return lines.join("\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function handleTokenInfo(
|
|
130
|
+
request: AiRequest,
|
|
131
|
+
): Promise<AiResponse> {
|
|
132
|
+
const trimmed = request.input?.trim() ?? "";
|
|
133
|
+
const symbolCandidate = extractSymbolCandidate(trimmed);
|
|
134
|
+
|
|
135
|
+
if (!symbolCandidate) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
provider: "openai",
|
|
139
|
+
mode: "token_info",
|
|
140
|
+
error: "Could not detect a token symbol. Try sending something like USDT.",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const tokenResult: TokenSearchResult = await getTokenBySymbol(
|
|
145
|
+
symbolCandidate,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (!tokenResult.ok) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
provider: "openai",
|
|
152
|
+
mode: "token_info",
|
|
153
|
+
error:
|
|
154
|
+
tokenResult.error === "not_found"
|
|
155
|
+
? `Token ${symbolCandidate} was not found on TON.`
|
|
156
|
+
: "Token service is temporarily unavailable.",
|
|
157
|
+
meta: {
|
|
158
|
+
symbol: symbolCandidate,
|
|
159
|
+
reason: tokenResult.reason,
|
|
160
|
+
status_code: tokenResult.status_code,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const token = tokenResult.data;
|
|
166
|
+
const facts = buildTokenFactsBlock(symbolCandidate, token);
|
|
167
|
+
|
|
168
|
+
const promptParts = [
|
|
169
|
+
"You are a concise TON token analyst.",
|
|
170
|
+
"",
|
|
171
|
+
"Facts about the token:",
|
|
172
|
+
facts,
|
|
173
|
+
"",
|
|
174
|
+
"User question or context:",
|
|
175
|
+
trimmed,
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
const composedInput = promptParts.join("\n");
|
|
179
|
+
|
|
180
|
+
const result = await callOpenAiChat("token_info", {
|
|
181
|
+
input: composedInput,
|
|
182
|
+
userId: request.userId,
|
|
183
|
+
context: {
|
|
184
|
+
...request.context,
|
|
185
|
+
symbol: symbolCandidate,
|
|
186
|
+
token,
|
|
187
|
+
source: "swap.coffee",
|
|
188
|
+
},
|
|
189
|
+
instructions: request.instructions,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
...result,
|
|
194
|
+
mode: "token_info",
|
|
195
|
+
meta: {
|
|
196
|
+
...(result.meta ?? {}),
|
|
197
|
+
symbol: symbolCandidate,
|
|
198
|
+
token,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function transmit(request: AiRequest): Promise<AiResponse> {
|
|
204
|
+
const mode: AiMode = request.mode ?? "chat";
|
|
205
|
+
const thread = request.threadContext;
|
|
206
|
+
|
|
207
|
+
if (thread && !thread.skipClaim) {
|
|
208
|
+
const skipped = await claimUserMessage(thread, request.input);
|
|
209
|
+
if (skipped) return skipped;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (mode === "token_info") {
|
|
213
|
+
const result = await handleTokenInfo(request);
|
|
214
|
+
if (result.ok && result.output_text && thread) {
|
|
215
|
+
await persistAssistantMessage(thread, result.output_text);
|
|
216
|
+
}
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let input = request.input;
|
|
221
|
+
if (thread) {
|
|
222
|
+
const history = await getThreadHistory({
|
|
223
|
+
user_telegram: thread.user_telegram,
|
|
224
|
+
thread_id: thread.thread_id,
|
|
225
|
+
type: thread.type,
|
|
226
|
+
limit: HISTORY_LIMIT,
|
|
227
|
+
});
|
|
228
|
+
input = formatHistoryForInput(history) + "Current message:\nuser: " + request.input;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const result = await callOpenAiChat(mode, {
|
|
232
|
+
input,
|
|
233
|
+
userId: request.userId,
|
|
234
|
+
context: request.context,
|
|
235
|
+
instructions: request.instructions,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (result.ok && result.output_text && thread) {
|
|
239
|
+
await persistAssistantMessage(thread, result.output_text);
|
|
240
|
+
}
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Stream AI response; onDelta(accumulatedText) is called for each chunk. Only the final OpenAI call is streamed. */
|
|
245
|
+
export async function transmitStream(
|
|
246
|
+
request: AiRequest,
|
|
247
|
+
onDelta: (text: string) => void | Promise<void>,
|
|
248
|
+
opts?: { isCancelled?: () => boolean; getAbortSignal?: () => Promise<boolean> },
|
|
249
|
+
): Promise<AiResponse> {
|
|
250
|
+
const mode: AiMode = request.mode ?? "chat";
|
|
251
|
+
const thread = request.threadContext;
|
|
252
|
+
|
|
253
|
+
if (thread && !thread.skipClaim) {
|
|
254
|
+
const skipped = await claimUserMessage(thread, request.input);
|
|
255
|
+
if (skipped) return skipped;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (mode === "token_info") {
|
|
259
|
+
const tokenResult = await (async () => {
|
|
260
|
+
const trimmed = request.input?.trim() ?? "";
|
|
261
|
+
const symbolCandidate = extractSymbolCandidate(trimmed);
|
|
262
|
+
if (!symbolCandidate) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
return getTokenBySymbol(symbolCandidate);
|
|
266
|
+
})();
|
|
267
|
+
|
|
268
|
+
if (!tokenResult) {
|
|
269
|
+
return {
|
|
270
|
+
ok: false,
|
|
271
|
+
provider: "openai",
|
|
272
|
+
mode: "token_info",
|
|
273
|
+
error: "Could not detect a token symbol. Try sending something like USDT.",
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (!tokenResult.ok) {
|
|
277
|
+
const symbolCandidate = extractSymbolCandidate(request.input?.trim() ?? "");
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
provider: "openai",
|
|
281
|
+
mode: "token_info",
|
|
282
|
+
error:
|
|
283
|
+
tokenResult.error === "not_found"
|
|
284
|
+
? `Token ${symbolCandidate ?? ""} was not found on TON.`
|
|
285
|
+
: "Token service is temporarily unavailable.",
|
|
286
|
+
meta: {
|
|
287
|
+
symbol: tokenResult.symbol,
|
|
288
|
+
reason: tokenResult.reason,
|
|
289
|
+
status_code: tokenResult.status_code,
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const token = tokenResult.data;
|
|
295
|
+
const symbolCandidate = extractSymbolCandidate(request.input?.trim() ?? "")!;
|
|
296
|
+
const facts = buildTokenFactsBlock(symbolCandidate, token);
|
|
297
|
+
const trimmed = request.input?.trim() ?? "";
|
|
298
|
+
const promptParts = [
|
|
299
|
+
"You are a concise TON token analyst.",
|
|
300
|
+
"",
|
|
301
|
+
"Facts about the token:",
|
|
302
|
+
facts,
|
|
303
|
+
"",
|
|
304
|
+
"User question or context:",
|
|
305
|
+
trimmed,
|
|
306
|
+
];
|
|
307
|
+
const composedInput = promptParts.join("\n");
|
|
308
|
+
|
|
309
|
+
const result = await callOpenAiChatStream(
|
|
310
|
+
"token_info",
|
|
311
|
+
{
|
|
312
|
+
input: composedInput,
|
|
313
|
+
userId: request.userId,
|
|
314
|
+
context: {
|
|
315
|
+
...request.context,
|
|
316
|
+
symbol: symbolCandidate,
|
|
317
|
+
token,
|
|
318
|
+
source: "swap.coffee",
|
|
319
|
+
},
|
|
320
|
+
instructions: request.instructions,
|
|
321
|
+
},
|
|
322
|
+
onDelta,
|
|
323
|
+
opts,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
if (result.ok && result.output_text && thread) {
|
|
327
|
+
await persistAssistantMessage(thread, result.output_text);
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
...result,
|
|
331
|
+
mode: "token_info",
|
|
332
|
+
meta: {
|
|
333
|
+
...(result.meta ?? {}),
|
|
334
|
+
symbol: symbolCandidate,
|
|
335
|
+
token,
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let input = request.input;
|
|
341
|
+
if (thread) {
|
|
342
|
+
const history = await getThreadHistory({
|
|
343
|
+
user_telegram: thread.user_telegram,
|
|
344
|
+
thread_id: thread.thread_id,
|
|
345
|
+
type: thread.type,
|
|
346
|
+
limit: HISTORY_LIMIT,
|
|
347
|
+
});
|
|
348
|
+
input = formatHistoryForInput(history) + "Current message:\nuser: " + request.input;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const result = await callOpenAiChatStream(
|
|
352
|
+
mode,
|
|
353
|
+
{
|
|
354
|
+
input,
|
|
355
|
+
userId: request.userId,
|
|
356
|
+
context: request.context,
|
|
357
|
+
instructions: request.instructions,
|
|
358
|
+
},
|
|
359
|
+
onDelta,
|
|
360
|
+
opts,
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
if (result.ok && result.output_text && thread) {
|
|
364
|
+
await persistAssistantMessage(thread, result.output_text);
|
|
365
|
+
}
|
|
366
|
+
return result;
|
|
367
|
+
}
|
package/api/{base.ts → _base.ts}
RENAMED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - EXPO_PUBLIC_API_BASE_URL (explicit override for any environment)
|
|
6
6
|
* - React Native / Expo Go dev: derive from dev server host (port 3000)
|
|
7
7
|
* - Browser:
|
|
8
|
-
* - In dev: map localhost/LAN + dev port (8081/19000/19006)
|
|
8
|
+
* - In dev: map localhost/LAN + dev port (8081/19000/19006) -> port 3000
|
|
9
9
|
* - In prod: window.location.origin (e.g. https://hsbexpo.vercel.app)
|
|
10
10
|
* - Node (no window): Vercel host if available, otherwise http://localhost:3000
|
|
11
11
|
*/
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { normalizeUsername } from '../../database/users.js';
|
|
3
|
+
|
|
4
|
+
type TelegramUserPayload = {
|
|
5
|
+
username?: string;
|
|
6
|
+
language_code?: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type VerifiedInitData = {
|
|
11
|
+
user?: TelegramUserPayload;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const TELEGRAM_WEBAPP_PUBLIC_KEY_RAW = Buffer.from(
|
|
16
|
+
'e7bf03a2fa4602af4580703d88dda5bb59f32ed8b02a56c187fe7d34caed242d',
|
|
17
|
+
'hex',
|
|
18
|
+
);
|
|
19
|
+
const ED25519_SPKI_HEADER = Buffer.from('302a300506032b6570032100', 'hex');
|
|
20
|
+
const TELEGRAM_WEBAPP_PUBLIC_KEY = crypto.createPublicKey({
|
|
21
|
+
key: Buffer.concat([
|
|
22
|
+
ED25519_SPKI_HEADER,
|
|
23
|
+
Buffer.from([0]),
|
|
24
|
+
TELEGRAM_WEBAPP_PUBLIC_KEY_RAW,
|
|
25
|
+
]),
|
|
26
|
+
format: 'der',
|
|
27
|
+
type: 'spki',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function verifyTelegramWebAppInitData(
|
|
31
|
+
initData: string,
|
|
32
|
+
botToken: string,
|
|
33
|
+
maxAgeSeconds: number = 24 * 3600,
|
|
34
|
+
): VerifiedInitData | null {
|
|
35
|
+
if (!initData || !botToken) return null;
|
|
36
|
+
try {
|
|
37
|
+
const params = new URLSearchParams(initData);
|
|
38
|
+
const data: Record<string, string> = {};
|
|
39
|
+
for (const [key, value] of params.entries()) data[key] = value;
|
|
40
|
+
|
|
41
|
+
const authDateStr = data.auth_date;
|
|
42
|
+
if (authDateStr) {
|
|
43
|
+
const authDate = Number(authDateStr);
|
|
44
|
+
if (!Number.isFinite(authDate)) return null;
|
|
45
|
+
const now = Math.floor(Date.now() / 1000);
|
|
46
|
+
if (authDate > now + 60) return null;
|
|
47
|
+
if (maxAgeSeconds != null && now - authDate > maxAgeSeconds) return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const receivedHash = data.hash;
|
|
51
|
+
const receivedSignature = data.signature;
|
|
52
|
+
|
|
53
|
+
if (receivedHash) {
|
|
54
|
+
const dataForHash = { ...data };
|
|
55
|
+
delete dataForHash.hash;
|
|
56
|
+
const sorted = Object.keys(dataForHash)
|
|
57
|
+
.sort()
|
|
58
|
+
.map((k) => `${k}=${dataForHash[k]}`)
|
|
59
|
+
.join('\n');
|
|
60
|
+
const dataCheckString = Buffer.from(sorted, 'utf8');
|
|
61
|
+
const secretKey = crypto
|
|
62
|
+
.createHmac('sha256', 'WebAppData')
|
|
63
|
+
.update(botToken)
|
|
64
|
+
.digest();
|
|
65
|
+
const computedHash = crypto
|
|
66
|
+
.createHmac('sha256', secretKey)
|
|
67
|
+
.update(dataCheckString)
|
|
68
|
+
.digest('hex');
|
|
69
|
+
const valid =
|
|
70
|
+
receivedHash.length === computedHash.length &&
|
|
71
|
+
crypto.timingSafeEqual(
|
|
72
|
+
Buffer.from(receivedHash, 'hex'),
|
|
73
|
+
Buffer.from(computedHash, 'hex'),
|
|
74
|
+
);
|
|
75
|
+
if (!valid) return null;
|
|
76
|
+
} else if (receivedSignature) {
|
|
77
|
+
const botId = botToken.split(':')[0];
|
|
78
|
+
if (!botId) return null;
|
|
79
|
+
const dataForSig = { ...data };
|
|
80
|
+
delete dataForSig.hash;
|
|
81
|
+
delete dataForSig.signature;
|
|
82
|
+
const sorted = Object.keys(dataForSig)
|
|
83
|
+
.sort()
|
|
84
|
+
.map((k) => `${k}=${dataForSig[k]}`)
|
|
85
|
+
.join('\n');
|
|
86
|
+
const dataCheckString = `${botId}:WebAppData\n${sorted}`;
|
|
87
|
+
const base64 = receivedSignature.replace(/-/g, '+').replace(/_/g, '/');
|
|
88
|
+
const pad = (4 - (base64.length % 4)) % 4;
|
|
89
|
+
const sigBuffer = Buffer.from(base64 + '='.repeat(pad), 'base64');
|
|
90
|
+
const ok = crypto.verify(
|
|
91
|
+
null,
|
|
92
|
+
Buffer.from(dataCheckString, 'utf8'),
|
|
93
|
+
TELEGRAM_WEBAPP_PUBLIC_KEY,
|
|
94
|
+
sigBuffer,
|
|
95
|
+
);
|
|
96
|
+
if (!ok) return null;
|
|
97
|
+
} else {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result: VerifiedInitData = { ...data };
|
|
102
|
+
delete result.hash;
|
|
103
|
+
delete result.signature;
|
|
104
|
+
if (data.user) {
|
|
105
|
+
try {
|
|
106
|
+
result.user = JSON.parse(data.user) as TelegramUserPayload;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type AuthResult = {
|
|
118
|
+
telegramUsername: string;
|
|
119
|
+
locale: string | null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export function authByInitData(initData: string): AuthResult {
|
|
123
|
+
const botToken = (process.env.BOT_TOKEN || '').trim();
|
|
124
|
+
if (!botToken) {
|
|
125
|
+
throw new Error('bot_token_not_configured');
|
|
126
|
+
}
|
|
127
|
+
const verified = verifyTelegramWebAppInitData(initData, botToken);
|
|
128
|
+
if (!verified) {
|
|
129
|
+
throw new Error('invalid_initdata');
|
|
130
|
+
}
|
|
131
|
+
const user =
|
|
132
|
+
verified.user && typeof verified.user === 'object'
|
|
133
|
+
? (verified.user as TelegramUserPayload)
|
|
134
|
+
: {};
|
|
135
|
+
const telegramUsername = normalizeUsername(user.username);
|
|
136
|
+
if (!telegramUsername) {
|
|
137
|
+
throw new Error('username_required');
|
|
138
|
+
}
|
|
139
|
+
const locale =
|
|
140
|
+
typeof user.language_code === 'string' ? user.language_code : null;
|
|
141
|
+
return { telegramUsername, locale };
|
|
142
|
+
}
|
|
143
|
+
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { registerWallet } from '../../database/wallets.js';
|
|
2
|
+
import { upsertUserFromTma } from '../../database/users.js';
|
|
3
|
+
import { authByInitData } from './_auth.js';
|
|
4
|
+
|
|
5
|
+
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
|
6
|
+
|
|
7
|
+
function jsonResponse(body: object, status: number): Response {
|
|
8
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type RegisterRequestBody = {
|
|
12
|
+
initData?: unknown;
|
|
13
|
+
wallet_address?: unknown;
|
|
14
|
+
wallet_blockchain?: unknown;
|
|
15
|
+
wallet_net?: unknown;
|
|
16
|
+
type?: unknown;
|
|
17
|
+
label?: unknown;
|
|
18
|
+
source?: unknown;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async function getBody(
|
|
23
|
+
request:
|
|
24
|
+
| Request
|
|
25
|
+
| {
|
|
26
|
+
json?: () => Promise<unknown>;
|
|
27
|
+
body?: unknown;
|
|
28
|
+
},
|
|
29
|
+
): Promise<unknown> {
|
|
30
|
+
if (typeof (request as { json?: () => Promise<unknown> }).json === 'function') {
|
|
31
|
+
return (request as Request).json();
|
|
32
|
+
}
|
|
33
|
+
return (request as { body?: unknown }).body ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasSensitiveFields(body: RegisterRequestBody): boolean {
|
|
37
|
+
const forbidden = [
|
|
38
|
+
'mnemonic',
|
|
39
|
+
'seed',
|
|
40
|
+
'private_key',
|
|
41
|
+
'secret',
|
|
42
|
+
'secret_key',
|
|
43
|
+
'wallet_master_key',
|
|
44
|
+
'wallet_seed_cipher',
|
|
45
|
+
];
|
|
46
|
+
return forbidden.some((key) => key in body);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toTrimmedString(value: unknown): string {
|
|
50
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function handler(request: Request): Promise<Response> {
|
|
54
|
+
const method = (request as { method?: string }).method ?? request.method;
|
|
55
|
+
if (method === 'GET') {
|
|
56
|
+
return jsonResponse(
|
|
57
|
+
{
|
|
58
|
+
ok: true,
|
|
59
|
+
endpoint: 'wallet/register',
|
|
60
|
+
use: 'POST with initData + public wallet fields',
|
|
61
|
+
},
|
|
62
|
+
200,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (method !== 'POST') {
|
|
66
|
+
return new Response('Method Not Allowed', { status: 405 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const rawBody = (await getBody(request)) as RegisterRequestBody | null;
|
|
70
|
+
if (!rawBody || typeof rawBody !== 'object') {
|
|
71
|
+
return jsonResponse({ ok: false, error: 'bad_json' }, 400);
|
|
72
|
+
}
|
|
73
|
+
if (hasSensitiveFields(rawBody)) {
|
|
74
|
+
return jsonResponse(
|
|
75
|
+
{ ok: false, error: 'sensitive_fields_not_allowed' },
|
|
76
|
+
400,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const initData = toTrimmedString(rawBody.initData);
|
|
81
|
+
if (!initData) return jsonResponse({ ok: false, error: 'missing_initData' }, 400);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const auth = authByInitData(initData);
|
|
85
|
+
await upsertUserFromTma({
|
|
86
|
+
telegramUsername: auth.telegramUsername,
|
|
87
|
+
locale: auth.locale,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const wallet_address = toTrimmedString(rawBody.wallet_address);
|
|
91
|
+
const wallet_blockchain = toTrimmedString(rawBody.wallet_blockchain);
|
|
92
|
+
const wallet_net = toTrimmedString(rawBody.wallet_net);
|
|
93
|
+
const type = toTrimmedString(rawBody.type);
|
|
94
|
+
const label = toTrimmedString(rawBody.label);
|
|
95
|
+
const source = toTrimmedString(rawBody.source);
|
|
96
|
+
|
|
97
|
+
if (!wallet_address || !wallet_blockchain || !wallet_net || !type) {
|
|
98
|
+
return jsonResponse(
|
|
99
|
+
{
|
|
100
|
+
ok: false,
|
|
101
|
+
error:
|
|
102
|
+
'required_fields_missing (wallet_address, wallet_blockchain, wallet_net, type)',
|
|
103
|
+
},
|
|
104
|
+
400,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const wallet = await registerWallet({
|
|
109
|
+
telegramUsername: auth.telegramUsername,
|
|
110
|
+
walletAddress: wallet_address,
|
|
111
|
+
walletBlockchain: wallet_blockchain,
|
|
112
|
+
walletNet: wallet_net,
|
|
113
|
+
type,
|
|
114
|
+
label: label || null,
|
|
115
|
+
source: source || null,
|
|
116
|
+
isDefault: true,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (!wallet) {
|
|
120
|
+
return jsonResponse({ ok: false, error: 'wallet_register_failed' }, 500);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return jsonResponse(
|
|
124
|
+
{
|
|
125
|
+
ok: true,
|
|
126
|
+
telegram_username: auth.telegramUsername,
|
|
127
|
+
has_wallet: true,
|
|
128
|
+
wallet: {
|
|
129
|
+
id: wallet.id,
|
|
130
|
+
wallet_address: wallet.wallet_address,
|
|
131
|
+
wallet_blockchain: wallet.wallet_blockchain,
|
|
132
|
+
wallet_net: wallet.wallet_net,
|
|
133
|
+
type: wallet.type,
|
|
134
|
+
label: wallet.label,
|
|
135
|
+
is_default: wallet.is_default,
|
|
136
|
+
source: wallet.source,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
200,
|
|
140
|
+
);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
const msg = err instanceof Error ? err.message : 'internal_error';
|
|
143
|
+
const status = msg === 'bot_token_not_configured' ? 500 : msg === 'invalid_initdata' ? 401 : 400;
|
|
144
|
+
return jsonResponse({ ok: false, error: msg }, status);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export default handler;
|
|
149
|
+
export const GET = handler;
|
|
150
|
+
export const POST = handler;
|
|
151
|
+
|