@www.hyperlinks.space/program-kit 1.2.91881 → 81.81.81
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/.env.example +19 -0
- package/README.md +3 -3
- 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/database/start.ts +0 -1
- package/database/wallets.ts +266 -0
- package/docs/auth-and-centralized-encrypted-keys-plan.md +440 -0
- package/docs/keys-retrieval-console-scripts.js +131 -0
- package/docs/security_plan_raw.md +1 -1
- package/docs/security_raw.md +22 -13
- package/docs/storage-availability-console-script.js +152 -0
- package/docs/storage-lifetime.md +33 -0
- package/docs/telegram-raw-keys-cloud-storage-risks.md +31 -0
- package/docs/wallets_hosting_architecture.md +147 -1
- package/fullREADME.md +71 -26
- package/index.js +3 -0
- package/npmReadMe.md +3 -3
- package/package.json +7 -2
- package/scripts/test-api-base.ts +2 -2
- package/telegram/post.ts +44 -8
package/app/index.tsx
CHANGED
|
@@ -1,8 +1,288 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { Button, Text, View } from "react-native";
|
|
2
3
|
import { useTelegram } from "../ui/components/Telegram";
|
|
4
|
+
import {
|
|
5
|
+
createSeedCipher,
|
|
6
|
+
deriveAddressFromMnemonic,
|
|
7
|
+
deriveMasterKeyFromMnemonic,
|
|
8
|
+
generateMnemonic,
|
|
9
|
+
} from "../services/wallet/tonWallet";
|
|
10
|
+
|
|
11
|
+
type CreateStep = "idle" | "saving" | "done";
|
|
12
|
+
|
|
13
|
+
type TelegramWebAppBridge = {
|
|
14
|
+
SecureStorage?: {
|
|
15
|
+
setItem?: (key: string, value: string, callback?: (err: unknown, stored?: boolean) => void) => void;
|
|
16
|
+
};
|
|
17
|
+
DeviceStorage?: {
|
|
18
|
+
setItem?: (key: string, value: string, callback?: (err: unknown, stored?: boolean) => void) => void;
|
|
19
|
+
};
|
|
20
|
+
CloudStorage?: {
|
|
21
|
+
setItem?: (key: string, value: string, callback?: (err: unknown, stored?: boolean) => void) => void;
|
|
22
|
+
};
|
|
23
|
+
onEvent?: (eventType: string, callback: (...args: unknown[]) => void) => void;
|
|
24
|
+
offEvent?: (eventType: string, callback: (...args: unknown[]) => void) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function getTelegramWebApp(): TelegramWebAppBridge | undefined {
|
|
28
|
+
if (typeof window === "undefined") return undefined;
|
|
29
|
+
return (window as unknown as { Telegram?: { WebApp?: TelegramWebAppBridge } }).Telegram?.WebApp;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* SecureStorage.setItem: on error the first callback argument is the error; on success it is null
|
|
34
|
+
* and the second is whether the value was stored. Some clients also emit web events; see
|
|
35
|
+
* https://core.telegram.org/bots/webapps#securestorage
|
|
36
|
+
*/
|
|
37
|
+
async function setTmaSecureStorageItem(key: string, value: string): Promise<boolean> {
|
|
38
|
+
const webApp = getTelegramWebApp();
|
|
39
|
+
if (!webApp) return false;
|
|
40
|
+
const storage = webApp.SecureStorage;
|
|
41
|
+
const setItem = storage?.setItem;
|
|
42
|
+
if (typeof setItem !== "function") return false;
|
|
43
|
+
|
|
44
|
+
return new Promise<boolean>((resolve) => {
|
|
45
|
+
let settled = false;
|
|
46
|
+
const finish = (ok: boolean) => {
|
|
47
|
+
if (settled) return;
|
|
48
|
+
settled = true;
|
|
49
|
+
cleanup();
|
|
50
|
+
resolve(ok);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const onSecureStorageFailed = (payload?: unknown) => {
|
|
54
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
55
|
+
console.warn("[wallet] secure_storage_failed", payload);
|
|
56
|
+
}
|
|
57
|
+
finish(false);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Fired when a value was saved (bridge may deliver this if the JS callback is delayed). */
|
|
61
|
+
const onSecureStorageKeySaved = () => {
|
|
62
|
+
finish(true);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const cleanup = () => {
|
|
66
|
+
try {
|
|
67
|
+
webApp.offEvent?.("secure_storage_failed", onSecureStorageFailed);
|
|
68
|
+
} catch {
|
|
69
|
+
/* ignore */
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
webApp.offEvent?.("secure_storage_key_saved", onSecureStorageKeySaved);
|
|
73
|
+
} catch {
|
|
74
|
+
/* ignore */
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
webApp.onEvent?.("secure_storage_failed", onSecureStorageFailed);
|
|
80
|
+
webApp.onEvent?.("secure_storage_key_saved", onSecureStorageKeySaved);
|
|
81
|
+
} catch {
|
|
82
|
+
/* older clients */
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
setItem(key, value, (err: unknown, stored?: boolean) => {
|
|
87
|
+
if (err != null) {
|
|
88
|
+
finish(false);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
finish(stored !== false);
|
|
92
|
+
});
|
|
93
|
+
} catch {
|
|
94
|
+
cleanup();
|
|
95
|
+
resolve(false);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* DeviceStorage: persistent local KV inside the Telegram client (not Keychain/Keystore).
|
|
102
|
+
* Same callback shape as SecureStorage; some clients emit device_storage_* web events.
|
|
103
|
+
* @see https://core.telegram.org/bots/webapps#devicestorage
|
|
104
|
+
*/
|
|
105
|
+
async function setTmaDeviceStorageItem(key: string, value: string): Promise<boolean> {
|
|
106
|
+
const webApp = getTelegramWebApp();
|
|
107
|
+
if (!webApp) return false;
|
|
108
|
+
const storage = webApp.DeviceStorage;
|
|
109
|
+
const setItem = storage?.setItem;
|
|
110
|
+
if (typeof setItem !== "function") return false;
|
|
111
|
+
|
|
112
|
+
return new Promise<boolean>((resolve) => {
|
|
113
|
+
let settled = false;
|
|
114
|
+
const finish = (ok: boolean) => {
|
|
115
|
+
if (settled) return;
|
|
116
|
+
settled = true;
|
|
117
|
+
cleanup();
|
|
118
|
+
resolve(ok);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const onDeviceStorageFailed = (payload?: unknown) => {
|
|
122
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
123
|
+
console.warn("[wallet] device_storage_failed", payload);
|
|
124
|
+
}
|
|
125
|
+
finish(false);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const onDeviceStorageKeySaved = () => {
|
|
129
|
+
finish(true);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const cleanup = () => {
|
|
133
|
+
try {
|
|
134
|
+
webApp.offEvent?.("device_storage_failed", onDeviceStorageFailed);
|
|
135
|
+
} catch {
|
|
136
|
+
/* ignore */
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
webApp.offEvent?.("device_storage_key_saved", onDeviceStorageKeySaved);
|
|
140
|
+
} catch {
|
|
141
|
+
/* ignore */
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
webApp.onEvent?.("device_storage_failed", onDeviceStorageFailed);
|
|
147
|
+
webApp.onEvent?.("device_storage_key_saved", onDeviceStorageKeySaved);
|
|
148
|
+
} catch {
|
|
149
|
+
/* older clients */
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
setItem(key, value, (err: unknown, stored?: boolean) => {
|
|
154
|
+
if (err != null) {
|
|
155
|
+
finish(false);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
finish(stored !== false);
|
|
159
|
+
});
|
|
160
|
+
} catch {
|
|
161
|
+
cleanup();
|
|
162
|
+
resolve(false);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type WalletMasterKeyStorageTier = "secure" | "device" | "none";
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Prefer hardware-backed SecureStorage; if missing or UNSUPPORTED, fall back to DeviceStorage
|
|
171
|
+
* so Desktop and older clients can still persist the key (weaker — see docs/security_raw.md).
|
|
172
|
+
*/
|
|
173
|
+
async function persistWalletMasterKey(masterKey: string): Promise<WalletMasterKeyStorageTier> {
|
|
174
|
+
const okSecure = await setTmaSecureStorageItem("wallet_master_key", masterKey);
|
|
175
|
+
if (okSecure) return "secure";
|
|
176
|
+
|
|
177
|
+
const okDevice = await setTmaDeviceStorageItem("wallet_master_key", masterKey);
|
|
178
|
+
if (okDevice) return "device";
|
|
179
|
+
|
|
180
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
181
|
+
console.warn("[wallet] wallet_master_key not persisted (no SecureStorage nor DeviceStorage)");
|
|
182
|
+
}
|
|
183
|
+
return "none";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** CloudStorage.setItem uses the same callback shape as SecureStorage. */
|
|
187
|
+
async function setTmaCloudStorageItem(key: string, value: string): Promise<boolean> {
|
|
188
|
+
const webApp = getTelegramWebApp();
|
|
189
|
+
const storage = webApp?.CloudStorage;
|
|
190
|
+
const setItem = storage?.setItem;
|
|
191
|
+
if (typeof setItem !== "function") return false;
|
|
192
|
+
|
|
193
|
+
return new Promise<boolean>((resolve) => {
|
|
194
|
+
try {
|
|
195
|
+
setItem(key, value, (err: unknown, stored?: boolean) => {
|
|
196
|
+
if (err != null) {
|
|
197
|
+
resolve(false);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
resolve(stored !== false);
|
|
201
|
+
});
|
|
202
|
+
} catch {
|
|
203
|
+
resolve(false);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
3
207
|
|
|
4
208
|
export default function Index() {
|
|
5
|
-
const {
|
|
209
|
+
const {
|
|
210
|
+
status,
|
|
211
|
+
telegramUsername,
|
|
212
|
+
hasWallet,
|
|
213
|
+
walletRequired,
|
|
214
|
+
wallet,
|
|
215
|
+
initData,
|
|
216
|
+
error,
|
|
217
|
+
debug,
|
|
218
|
+
} = useTelegram();
|
|
219
|
+
const [step, setStep] = useState<CreateStep>("idle");
|
|
220
|
+
const [flowError, setFlowError] = useState<string | null>(null);
|
|
221
|
+
const [createdWalletAddress, setCreatedWalletAddress] = useState<string | null>(null);
|
|
222
|
+
const [masterKeyStorageTier, setMasterKeyStorageTier] = useState<WalletMasterKeyStorageTier | null>(null);
|
|
223
|
+
const effectiveWalletAddress = wallet?.wallet_address ?? createdWalletAddress;
|
|
224
|
+
const effectiveHasWallet = hasWallet || Boolean(createdWalletAddress);
|
|
225
|
+
|
|
226
|
+
const createAndRegisterWalletFlow = useCallback(async () => {
|
|
227
|
+
if (!initData) {
|
|
228
|
+
setFlowError("Missing Telegram initData.");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
setFlowError(null);
|
|
232
|
+
setStep("saving");
|
|
233
|
+
try {
|
|
234
|
+
const mnemonic = await generateMnemonic();
|
|
235
|
+
const walletAddress = await deriveAddressFromMnemonic({ mnemonic, testnet: false });
|
|
236
|
+
const masterKey = await deriveMasterKeyFromMnemonic(mnemonic);
|
|
237
|
+
const seedCipher = await createSeedCipher(masterKey, mnemonic.join(" "));
|
|
238
|
+
|
|
239
|
+
// Register with API first: SecureStorage can fail/hang (see secure_storage_failed) and must not block.
|
|
240
|
+
const response = await fetch("/api/wallet/register", {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: { "Content-Type": "application/json" },
|
|
243
|
+
body: JSON.stringify({
|
|
244
|
+
initData,
|
|
245
|
+
wallet_address: walletAddress,
|
|
246
|
+
wallet_blockchain: "ton",
|
|
247
|
+
wallet_net: "mainnet",
|
|
248
|
+
type: "internal",
|
|
249
|
+
label: "Main wallet",
|
|
250
|
+
source: "miniapp",
|
|
251
|
+
}),
|
|
252
|
+
});
|
|
253
|
+
const json = await response.json().catch(() => ({}));
|
|
254
|
+
if (!response.ok || !json?.ok) {
|
|
255
|
+
throw new Error(json?.error || `HTTP ${response.status}`);
|
|
256
|
+
}
|
|
257
|
+
const [tier, cloudOk] = await Promise.all([
|
|
258
|
+
persistWalletMasterKey(masterKey),
|
|
259
|
+
setTmaCloudStorageItem("wallet_seed_cipher", seedCipher),
|
|
260
|
+
]);
|
|
261
|
+
setMasterKeyStorageTier(tier);
|
|
262
|
+
if (!cloudOk && typeof __DEV__ !== "undefined" && __DEV__) {
|
|
263
|
+
console.warn("[wallet] wallet_seed_cipher not saved to CloudStorage");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
setCreatedWalletAddress(walletAddress);
|
|
267
|
+
setStep("done");
|
|
268
|
+
} catch (e) {
|
|
269
|
+
setFlowError(e instanceof Error ? e.message : "Wallet registration failed");
|
|
270
|
+
setStep("idle");
|
|
271
|
+
}
|
|
272
|
+
}, [initData]);
|
|
273
|
+
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
if (
|
|
276
|
+
status === "ok" &&
|
|
277
|
+
walletRequired &&
|
|
278
|
+
!hasWallet &&
|
|
279
|
+
!createdWalletAddress &&
|
|
280
|
+
step === "idle" &&
|
|
281
|
+
initData
|
|
282
|
+
) {
|
|
283
|
+
void createAndRegisterWalletFlow();
|
|
284
|
+
}
|
|
285
|
+
}, [status, walletRequired, hasWallet, createdWalletAddress, step, initData, createAndRegisterWalletFlow]);
|
|
6
286
|
|
|
7
287
|
if (status === "idle" || status === "loading") {
|
|
8
288
|
return (
|
|
@@ -79,6 +359,21 @@ export default function Index() {
|
|
|
79
359
|
);
|
|
80
360
|
}
|
|
81
361
|
|
|
362
|
+
if (status === "ok" && walletRequired && !effectiveHasWallet) {
|
|
363
|
+
return (
|
|
364
|
+
<View style={{ flex: 1, justifyContent: "center", padding: 16, gap: 10 }}>
|
|
365
|
+
<Text style={{ fontWeight: "700", fontSize: 18 }}>Preparing your wallet</Text>
|
|
366
|
+
<Text>
|
|
367
|
+
No wallet found for this Telegram account. Creating one now and registering public wallet
|
|
368
|
+
data.
|
|
369
|
+
</Text>
|
|
370
|
+
{step === "saving" && <Text>Saving secrets and registering wallet...</Text>}
|
|
371
|
+
{flowError ? <Text style={{ color: "#b00020" }}>{flowError}</Text> : null}
|
|
372
|
+
{flowError ? <Button title="Retry wallet creation" onPress={createAndRegisterWalletFlow} /> : null}
|
|
373
|
+
</View>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
82
377
|
return (
|
|
83
378
|
<View
|
|
84
379
|
style={{
|
|
@@ -91,11 +386,30 @@ export default function Index() {
|
|
|
91
386
|
<Text style={{ fontWeight: "600", marginBottom: 8 }}>
|
|
92
387
|
HyperlinksSpace Wallet
|
|
93
388
|
</Text>
|
|
94
|
-
{telegramUsername
|
|
95
|
-
<Text style={{ textAlign: "center" }}>
|
|
389
|
+
{telegramUsername ? (
|
|
390
|
+
<Text style={{ textAlign: "center", marginBottom: 8 }}>
|
|
96
391
|
You are logged in via Telegram as @{telegramUsername}.
|
|
97
392
|
</Text>
|
|
98
|
-
)}
|
|
393
|
+
) : null}
|
|
394
|
+
{effectiveWalletAddress ? (
|
|
395
|
+
<View style={{ alignItems: "center" }}>
|
|
396
|
+
<Text style={{ textAlign: "center" }}>Wallet:</Text>
|
|
397
|
+
<Text style={{ textAlign: "center", marginTop: 4 }}>{effectiveWalletAddress}</Text>
|
|
398
|
+
</View>
|
|
399
|
+
) : null}
|
|
400
|
+
{masterKeyStorageTier === "device" ? (
|
|
401
|
+
<Text style={{ marginTop: 14, fontSize: 12, color: "#856404", textAlign: "center", paddingHorizontal: 8 }}>
|
|
402
|
+
This Telegram client does not support SecureStorage, so your wallet key was stored in DeviceStorage
|
|
403
|
+
(persistent app storage, not the system keychain). That is weaker than SecureStorage; keep your seed
|
|
404
|
+
phrase safe and prefer Telegram on iOS/Android when possible.
|
|
405
|
+
</Text>
|
|
406
|
+
) : null}
|
|
407
|
+
{masterKeyStorageTier === "none" ? (
|
|
408
|
+
<Text style={{ marginTop: 14, fontSize: 12, color: "#b00020", textAlign: "center", paddingHorizontal: 8 }}>
|
|
409
|
+
Could not save your wallet key on this device. Cloud ciphertext may still sync, but you will need your
|
|
410
|
+
recovery phrase to sign here until storage works. Try another Telegram client or update the app.
|
|
411
|
+
</Text>
|
|
412
|
+
) : null}
|
|
99
413
|
</View>
|
|
100
414
|
);
|
|
101
415
|
}
|
|
Binary file
|
package/database/start.ts
CHANGED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wallet helpers for the wallets table.
|
|
3
|
+
* Shared by API routes and bot-side services.
|
|
4
|
+
*/
|
|
5
|
+
import { sql } from './start.js';
|
|
6
|
+
import { normalizeUsername } from './users.js';
|
|
7
|
+
|
|
8
|
+
export type WalletRow = {
|
|
9
|
+
id: number;
|
|
10
|
+
telegram_username: string;
|
|
11
|
+
wallet_address: string;
|
|
12
|
+
wallet_blockchain: string;
|
|
13
|
+
wallet_net: string;
|
|
14
|
+
type: string;
|
|
15
|
+
label: string | null;
|
|
16
|
+
is_default: boolean;
|
|
17
|
+
source: string | null;
|
|
18
|
+
notes: string | null;
|
|
19
|
+
created_at: string;
|
|
20
|
+
updated_at: string;
|
|
21
|
+
last_used_at: string | null;
|
|
22
|
+
last_seen_balance_at: string | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type RegisterWalletInput = {
|
|
26
|
+
telegramUsername: string;
|
|
27
|
+
walletAddress: string;
|
|
28
|
+
walletBlockchain: string;
|
|
29
|
+
walletNet: string;
|
|
30
|
+
type: string;
|
|
31
|
+
label?: string | null;
|
|
32
|
+
source?: string | null;
|
|
33
|
+
notes?: string | null;
|
|
34
|
+
isDefault?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function normalizeText(raw: unknown): string {
|
|
38
|
+
if (typeof raw !== 'string') return '';
|
|
39
|
+
return raw.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeLower(raw: unknown): string {
|
|
43
|
+
return normalizeText(raw).toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeAddress(raw: unknown): string {
|
|
47
|
+
return normalizeText(raw);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function listWalletsByUsername(
|
|
51
|
+
telegramUsername: string,
|
|
52
|
+
): Promise<WalletRow[]> {
|
|
53
|
+
const username = normalizeUsername(telegramUsername);
|
|
54
|
+
if (!username) return [];
|
|
55
|
+
|
|
56
|
+
const rows = await sql<WalletRow[]>`
|
|
57
|
+
SELECT
|
|
58
|
+
id,
|
|
59
|
+
telegram_username,
|
|
60
|
+
wallet_address,
|
|
61
|
+
wallet_blockchain,
|
|
62
|
+
wallet_net,
|
|
63
|
+
type,
|
|
64
|
+
label,
|
|
65
|
+
is_default,
|
|
66
|
+
source,
|
|
67
|
+
notes,
|
|
68
|
+
created_at,
|
|
69
|
+
updated_at,
|
|
70
|
+
last_used_at,
|
|
71
|
+
last_seen_balance_at
|
|
72
|
+
FROM wallets
|
|
73
|
+
WHERE telegram_username = ${username}
|
|
74
|
+
ORDER BY is_default DESC, created_at ASC;
|
|
75
|
+
`;
|
|
76
|
+
return rows;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function getDefaultWalletByUsername(
|
|
80
|
+
telegramUsername: string,
|
|
81
|
+
): Promise<WalletRow | null> {
|
|
82
|
+
const username = normalizeUsername(telegramUsername);
|
|
83
|
+
if (!username) return null;
|
|
84
|
+
|
|
85
|
+
const rows = await sql<WalletRow[]>`
|
|
86
|
+
SELECT
|
|
87
|
+
id,
|
|
88
|
+
telegram_username,
|
|
89
|
+
wallet_address,
|
|
90
|
+
wallet_blockchain,
|
|
91
|
+
wallet_net,
|
|
92
|
+
type,
|
|
93
|
+
label,
|
|
94
|
+
is_default,
|
|
95
|
+
source,
|
|
96
|
+
notes,
|
|
97
|
+
created_at,
|
|
98
|
+
updated_at,
|
|
99
|
+
last_used_at,
|
|
100
|
+
last_seen_balance_at
|
|
101
|
+
FROM wallets
|
|
102
|
+
WHERE telegram_username = ${username}
|
|
103
|
+
ORDER BY is_default DESC, created_at ASC
|
|
104
|
+
LIMIT 1;
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
return rows[0] ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function setDefaultWallet(opts: {
|
|
111
|
+
telegramUsername: string;
|
|
112
|
+
walletAddress: string;
|
|
113
|
+
walletBlockchain: string;
|
|
114
|
+
walletNet: string;
|
|
115
|
+
}): Promise<WalletRow | null> {
|
|
116
|
+
const username = normalizeUsername(opts.telegramUsername);
|
|
117
|
+
const walletAddress = normalizeAddress(opts.walletAddress);
|
|
118
|
+
const walletBlockchain = normalizeLower(opts.walletBlockchain);
|
|
119
|
+
const walletNet = normalizeLower(opts.walletNet);
|
|
120
|
+
|
|
121
|
+
if (!username || !walletAddress || !walletBlockchain || !walletNet) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await sql`
|
|
126
|
+
UPDATE wallets
|
|
127
|
+
SET is_default = FALSE,
|
|
128
|
+
updated_at = NOW()
|
|
129
|
+
WHERE telegram_username = ${username};
|
|
130
|
+
`;
|
|
131
|
+
|
|
132
|
+
const rows = await sql<WalletRow[]>`
|
|
133
|
+
UPDATE wallets
|
|
134
|
+
SET is_default = TRUE,
|
|
135
|
+
updated_at = NOW()
|
|
136
|
+
WHERE telegram_username = ${username}
|
|
137
|
+
AND wallet_address = ${walletAddress}
|
|
138
|
+
AND wallet_blockchain = ${walletBlockchain}
|
|
139
|
+
AND wallet_net = ${walletNet}
|
|
140
|
+
RETURNING
|
|
141
|
+
id,
|
|
142
|
+
telegram_username,
|
|
143
|
+
wallet_address,
|
|
144
|
+
wallet_blockchain,
|
|
145
|
+
wallet_net,
|
|
146
|
+
type,
|
|
147
|
+
label,
|
|
148
|
+
is_default,
|
|
149
|
+
source,
|
|
150
|
+
notes,
|
|
151
|
+
created_at,
|
|
152
|
+
updated_at,
|
|
153
|
+
last_used_at,
|
|
154
|
+
last_seen_balance_at;
|
|
155
|
+
`;
|
|
156
|
+
|
|
157
|
+
const selected = rows[0] ?? null;
|
|
158
|
+
if (!selected) return null;
|
|
159
|
+
|
|
160
|
+
await sql`
|
|
161
|
+
UPDATE users
|
|
162
|
+
SET default_wallet = ${selected.id},
|
|
163
|
+
updated_at = NOW()
|
|
164
|
+
WHERE telegram_username = ${username};
|
|
165
|
+
`;
|
|
166
|
+
|
|
167
|
+
return selected;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function registerWallet(
|
|
171
|
+
input: RegisterWalletInput,
|
|
172
|
+
): Promise<WalletRow | null> {
|
|
173
|
+
const telegramUsername = normalizeUsername(input.telegramUsername);
|
|
174
|
+
const walletAddress = normalizeAddress(input.walletAddress);
|
|
175
|
+
const walletBlockchain = normalizeLower(input.walletBlockchain);
|
|
176
|
+
const walletNet = normalizeLower(input.walletNet);
|
|
177
|
+
const walletType = normalizeLower(input.type);
|
|
178
|
+
const label = normalizeText(input.label ?? '');
|
|
179
|
+
const source = normalizeText(input.source ?? '');
|
|
180
|
+
const notes = normalizeText(input.notes ?? '');
|
|
181
|
+
|
|
182
|
+
if (
|
|
183
|
+
!telegramUsername ||
|
|
184
|
+
!walletAddress ||
|
|
185
|
+
!walletBlockchain ||
|
|
186
|
+
!walletNet ||
|
|
187
|
+
!walletType
|
|
188
|
+
) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const rows = await sql<WalletRow[]>`
|
|
193
|
+
INSERT INTO wallets (
|
|
194
|
+
telegram_username,
|
|
195
|
+
wallet_address,
|
|
196
|
+
wallet_blockchain,
|
|
197
|
+
wallet_net,
|
|
198
|
+
type,
|
|
199
|
+
label,
|
|
200
|
+
source,
|
|
201
|
+
notes,
|
|
202
|
+
is_default,
|
|
203
|
+
created_at,
|
|
204
|
+
updated_at
|
|
205
|
+
)
|
|
206
|
+
VALUES (
|
|
207
|
+
${telegramUsername},
|
|
208
|
+
${walletAddress},
|
|
209
|
+
${walletBlockchain},
|
|
210
|
+
${walletNet},
|
|
211
|
+
${walletType},
|
|
212
|
+
${label || null},
|
|
213
|
+
${source || null},
|
|
214
|
+
${notes || null},
|
|
215
|
+
FALSE,
|
|
216
|
+
NOW(),
|
|
217
|
+
NOW()
|
|
218
|
+
)
|
|
219
|
+
ON CONFLICT (telegram_username, wallet_address, wallet_blockchain, wallet_net)
|
|
220
|
+
DO UPDATE SET
|
|
221
|
+
type = EXCLUDED.type,
|
|
222
|
+
label = EXCLUDED.label,
|
|
223
|
+
source = EXCLUDED.source,
|
|
224
|
+
notes = EXCLUDED.notes,
|
|
225
|
+
updated_at = NOW()
|
|
226
|
+
RETURNING
|
|
227
|
+
id,
|
|
228
|
+
telegram_username,
|
|
229
|
+
wallet_address,
|
|
230
|
+
wallet_blockchain,
|
|
231
|
+
wallet_net,
|
|
232
|
+
type,
|
|
233
|
+
label,
|
|
234
|
+
is_default,
|
|
235
|
+
source,
|
|
236
|
+
notes,
|
|
237
|
+
created_at,
|
|
238
|
+
updated_at,
|
|
239
|
+
last_used_at,
|
|
240
|
+
last_seen_balance_at;
|
|
241
|
+
`;
|
|
242
|
+
|
|
243
|
+
const wallet = rows[0] ?? null;
|
|
244
|
+
if (!wallet) return null;
|
|
245
|
+
|
|
246
|
+
if (input.isDefault) {
|
|
247
|
+
return setDefaultWallet({
|
|
248
|
+
telegramUsername,
|
|
249
|
+
walletAddress,
|
|
250
|
+
walletBlockchain,
|
|
251
|
+
walletNet,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const currentDefault = await getDefaultWalletByUsername(telegramUsername);
|
|
256
|
+
if (!currentDefault) {
|
|
257
|
+
return setDefaultWallet({
|
|
258
|
+
telegramUsername,
|
|
259
|
+
walletAddress,
|
|
260
|
+
walletBlockchain,
|
|
261
|
+
walletNet,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return wallet;
|
|
266
|
+
}
|