Monorepo for Aesthetic.Computer aesthetic.computer
at main 407 lines 12 kB view raw
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}