Monorepo for Aesthetic.Computer
aesthetic.computer
1/**
2 * cli-wallet.mjs - Tezos wallet integration for CLI tools
3 *
4 * Allows CLI users to:
5 * - Connect their Tezos wallet via QR code (Temple/Kukai)
6 * - Sign and pay for transactions themselves
7 * - Persist wallet connection for future use
8 * - Sync wallet address to MongoDB profile
9 *
10 * Connection methods:
11 * 1. QR Code scanning (Temple Beacon P2P or Kukai WalletConnect)
12 * 2. Manual address entry (fallback)
13 */
14
15import { TezosToolkit } from "@taquito/taquito";
16import { promises as fs } from "fs";
17import { join, dirname } from "path";
18import { fileURLToPath } from "url";
19import readline from "readline";
20
21const __dirname = dirname(fileURLToPath(import.meta.url));
22
23// Storage for wallet session
24const WALLET_FILE = join(process.env.HOME, ".ac-tezos-wallet");
25
26// Network config
27const NETWORKS = {
28 ghostnet: {
29 rpc: "https://ghostnet.ecadinfra.com",
30 tzkt: "https://api.ghostnet.tzkt.io",
31 name: "Ghostnet (Testnet)",
32 },
33 mainnet: {
34 rpc: "https://mainnet.ecadinfra.com",
35 tzkt: "https://api.tzkt.io",
36 name: "Mainnet",
37 },
38};
39
40// Terminal colors
41const RESET = '\x1b[0m';
42const BOLD = '\x1b[1m';
43const DIM = '\x1b[2m';
44const CYAN = '\x1b[36m';
45const GREEN = '\x1b[32m';
46const YELLOW = '\x1b[33m';
47const RED = '\x1b[31m';
48
49let tezos = null;
50let connectedAddress = null;
51let currentNetwork = "ghostnet";
52
53/**
54 * Initialize the Tezos toolkit
55 */
56export async function init(network = "ghostnet") {
57 currentNetwork = network;
58 const config = NETWORKS[network];
59
60 tezos = new TezosToolkit(config.rpc);
61
62 // Try to load saved session
63 const session = await loadSession();
64 if (session?.address) {
65 connectedAddress = session.address;
66 currentNetwork = session.network || network;
67 }
68
69 return { tezos, address: connectedAddress };
70}
71
72/**
73 * Connect wallet - prompt user to enter their address
74 */
75export async function connect(network = "ghostnet") {
76 currentNetwork = network;
77 const config = NETWORKS[network];
78
79 console.log(`\n${BOLD}${CYAN}╔════════════════════════════════════════════════════════════════╗${RESET}`);
80 console.log(`${BOLD}${CYAN}║ 🔷 Connect Tezos Wallet ║${RESET}`);
81 console.log(`${BOLD}${CYAN}╚════════════════════════════════════════════════════════════════╝${RESET}\n`);
82
83 console.log(`${DIM}Network: ${config.name}${RESET}\n`);
84 console.log(`Enter your Tezos wallet address or .tez domain:`);
85 console.log(`${DIM}Examples: tz1abc..., jeffrey.tez, or just "jeffrey"${RESET}\n`);
86
87 const rl = readline.createInterface({
88 input: process.stdin,
89 output: process.stdout
90 });
91
92 let input = await new Promise((resolve) => {
93 rl.question(`${CYAN}Address/Domain: ${RESET}`, (answer) => {
94 rl.close();
95 resolve(answer.trim());
96 });
97 });
98
99 let address = input;
100 let domain = null;
101
102 // Check if it's a domain (doesn't start with tz)
103 if (!input.match(/^tz[123]/)) {
104 console.log(`${DIM}Resolving domain...${RESET}`);
105 const resolved = await resolveDomain(input, network);
106 if (resolved) {
107 address = resolved;
108 domain = input.endsWith('.tez') ? input : `${input}.tez`;
109 console.log(`${GREEN}✓${RESET} ${domain} → ${address.slice(0, 8)}...${address.slice(-6)}\n`);
110 } else {
111 console.log(`${RED}❌ Could not resolve "${input}" to a Tezos address${RESET}`);
112 console.log(`${DIM}Make sure the domain exists on ${config.name}${RESET}\n`);
113 return null;
114 }
115 }
116
117 // Validate address format
118 if (!address.match(/^(tz1|tz2|tz3)[1-9A-HJ-NP-Za-km-z]{33}$/)) {
119 console.log(`${RED}❌ Invalid Tezos address format${RESET}\n`);
120 return null;
121 }
122
123 // Verify address exists on chain
124 const balance = await fetchBalance(address, network);
125 if (balance === null) {
126 console.log(`${YELLOW}⚠️ Could not verify address on ${config.name}${RESET}`);
127 console.log(`${DIM}The address may be new or network may be unavailable${RESET}\n`);
128 }
129
130 connectedAddress = address;
131
132 // Lookup domain if we didn't already resolve one
133 if (!domain) {
134 domain = await fetchDomain(address, network);
135 }
136
137 console.log(`\n${GREEN}✅ Wallet connected!${RESET}`);
138 if (domain) {
139 console.log(`${CYAN}Domain:${RESET} ${domain}`);
140 }
141 console.log(`${CYAN}Address:${RESET} ${address}`);
142 if (balance !== null) {
143 console.log(`${CYAN}Balance:${RESET} ${balance.toFixed(2)} ꜩ`);
144 }
145 console.log(`${CYAN}Network:${RESET} ${config.name}\n`);
146
147 // Save session (include domain if known)
148 await saveSession(address, network, domain);
149
150 return address;
151}
152
153/**
154 * Connect wallet via QR code (Temple/Kukai)
155 * Uses beacon-node.mjs or walletconnect-node.mjs under the hood
156 */
157export async function connectViaQR(walletType = "temple", network = "ghostnet") {
158 currentNetwork = network;
159 const config = NETWORKS[network];
160
161 console.log(`\n${BOLD}${CYAN}╔════════════════════════════════════════════════════════════════╗${RESET}`);
162 console.log(`${BOLD}${CYAN}║ 🔷 Connect Tezos Wallet via QR Code ║${RESET}`);
163 console.log(`${BOLD}${CYAN}╚════════════════════════════════════════════════════════════════╝${RESET}\n`);
164
165 console.log(`${DIM}Wallet: ${walletType === 'temple' ? 'Temple (Beacon P2P)' : 'Kukai (WalletConnect)'}${RESET}`);
166 console.log(`${DIM}Network: ${config.name}${RESET}\n`);
167
168 let address = null;
169 let domain = null;
170
171 try {
172 if (walletType === "temple") {
173 // Use Beacon P2P
174 const { pairWallet } = await import("./beacon-node.mjs");
175 const result = await pairWallet();
176
177 if (result?.permissionResponse) {
178 address = result.permissionResponse.address ||
179 result.permissionResponse.account?.address ||
180 result.permissionResponse.accountInfo?.address;
181 }
182 } else if (walletType === "kukai") {
183 // Use WalletConnect
184 if (!process.env.WALLETCONNECT_PROJECT_ID) {
185 console.log(`${RED}✗ Missing WALLETCONNECT_PROJECT_ID${RESET}`);
186 console.log(`${DIM}Get one free at https://cloud.walletconnect.com${RESET}\n`);
187 return null;
188 }
189
190 const { pairWalletWC } = await import("./walletconnect-node.mjs");
191 const result = await pairWalletWC();
192
193 if (result?.accounts?.length > 0) {
194 address = result.accounts[0].address;
195 }
196 }
197
198 if (!address) {
199 console.log(`${RED}✗ No address received from wallet${RESET}\n`);
200 return null;
201 }
202
203 connectedAddress = address;
204
205 // Lookup domain
206 domain = await fetchDomain(address, network);
207
208 console.log(`\n${GREEN}✅ Wallet connected via QR!${RESET}`);
209 if (domain) {
210 console.log(`${CYAN}Domain:${RESET} ${domain}`);
211 }
212 console.log(`${CYAN}Address:${RESET} ${address}`);
213 console.log(`${CYAN}Network:${RESET} ${config.name}\n`);
214
215 // Save session
216 await saveSession(address, network, domain);
217
218 return address;
219
220 } catch (err) {
221 // Handle user rejection gracefully
222 if (err.message?.includes('rejected') || err.message?.includes('User') || err.message?.includes('timeout')) {
223 console.log(`\n${YELLOW}⚠️ Connection declined or timed out${RESET}\n`);
224 return null;
225 }
226 console.log(`${RED}✗ QR connection failed: ${err.message}${RESET}\n`);
227 return null;
228 }
229}
230
231/**
232 * Disconnect wallet
233 */
234export async function disconnect() {
235 connectedAddress = null;
236 await fs.unlink(WALLET_FILE).catch(() => {});
237 console.log(`${GREEN}✅ Wallet disconnected${RESET}\n`);
238}
239
240/**
241 * Get connected address (or null)
242 */
243export function getAddress() {
244 return connectedAddress;
245}
246
247/**
248 * Check if connected
249 */
250export function isConnected() {
251 return connectedAddress !== null;
252}
253
254/**
255 * Get Tezos toolkit for operations
256 */
257export function getTezos() {
258 return tezos;
259}
260
261/**
262 * NOTE: CLI cannot sign transactions directly (no Beacon in Node.js)
263 * For now, we use server-side minting where admin pays gas
264 * and tokens go to the user's connected address.
265 *
266 * Future: Could integrate with Temple CLI or remote signing service
267 */
268
269/**
270 * Fetch .tez domain for an address (reverse lookup)
271 * NOTE: Always uses mainnet API since .tez domains only exist on mainnet
272 */
273export async function fetchDomain(address, _network = "ghostnet") {
274 try {
275 // Always use mainnet TzKT - .tez domains only exist on mainnet
276 const res = await fetch(`https://api.tzkt.io/v1/domains?address=${address}&reverse=true&select=name`);
277 if (res.ok) {
278 const data = await res.json();
279 if (data && data.length > 0) {
280 return data[0];
281 }
282 }
283 } catch (err) {
284 // Ignore
285 }
286 return null;
287}
288
289/**
290 * Resolve .tez domain to address
291 * NOTE: Always uses mainnet API since .tez domains only exist on mainnet
292 */
293export async function resolveDomain(domain, _network = "ghostnet") {
294 try {
295 // Normalize domain - add .tez if not present
296 const normalizedDomain = domain.endsWith('.tez') ? domain : `${domain}.tez`;
297
298 // Always use mainnet TzKT - .tez domains only exist on mainnet
299 const res = await fetch(`https://api.tzkt.io/v1/domains?name=${normalizedDomain}&select=address`);
300 if (res.ok) {
301 const data = await res.json();
302 if (data && data.length > 0 && data[0].address) {
303 return data[0].address; // Return just the address string
304 }
305 }
306 } catch (err) {
307 // Ignore
308 }
309 return null;
310}
311
312/**
313 * Fetch balance for an address
314 */
315export async function fetchBalance(address, network = "ghostnet") {
316 try {
317 const rpc = NETWORKS[network].rpc;
318 const res = await fetch(`${rpc}/chains/main/blocks/head/context/contracts/${address}/balance`);
319 if (res.ok) {
320 const mutez = await res.json();
321 return parseInt(mutez) / 1_000_000;
322 }
323 } catch (err) {
324 // Ignore
325 }
326 return null;
327}
328
329/**
330 * Save wallet session
331 */
332async function saveSession(address, network, domain = null) {
333 try {
334 await fs.writeFile(WALLET_FILE, JSON.stringify({
335 address,
336 network,
337 domain,
338 connectedAt: new Date().toISOString(),
339 }), "utf8");
340 } catch (err) {
341 // Ignore
342 }
343}
344
345/**
346 * Load saved session (for display only - actual session is in Beacon)
347 */
348export async function loadSession() {
349 try {
350 const data = await fs.readFile(WALLET_FILE, "utf8");
351 return JSON.parse(data);
352 } catch (err) {
353 return null;
354 }
355}
356
357/**
358 * Update user's Tezos address in AC database
359 * First tries API endpoint, falls back to direct DB if that fails
360 */
361export async function updateDatabaseAddress(address, network, token, userId = null) {
362 // Try API endpoint first (works when dev server is running)
363 try {
364 const endpoint = process.env.AC_ENDPOINT || "https://localhost:8888";
365 const response = await fetch(`${endpoint}/api/update-tezos-address`, {
366 method: "POST",
367 headers: {
368 "Content-Type": "application/json",
369 "Authorization": `Bearer ${token}`,
370 },
371 body: JSON.stringify({ address, network }),
372 });
373
374 if (response.ok) {
375 console.log(`${GREEN}✅ Saved address to AC profile${RESET}`);
376 return true;
377 }
378 } catch (err) {
379 // API not available, try direct DB
380 }
381
382 // Fallback: Direct database update (works in devcontainer)
383 if (userId) {
384 try {
385 const { connect } = await import("../system/backend/database.mjs");
386 const { db } = await connect();
387
388 await db.collection("users").updateOne(
389 { sub: userId },
390 {
391 $set: {
392 "tezos.address": address,
393 "tezos.network": network,
394 "tezos.connectedAt": new Date(),
395 },
396 }
397 );
398
399 console.log(`${GREEN}✅ Saved address to AC profile (direct)${RESET}`);
400 return true;
401 } catch (err) {
402 console.log(`${DIM}⚠️ Could not save to profile: ${err.message}${RESET}`);
403 }
404 }
405
406 return false;
407}