Monorepo for Aesthetic.Computer aesthetic.computer
at main 960 lines 36 kB view raw
1/** 2 * beacon-node.mjs - Node.js Beacon P2P client 3 * 4 * Direct port of Beacon SDK's Matrix transport to Node.js. 5 * Uses the same HTTP REST API that the browser SDK uses. 6 * 7 * Matrix Protocol (what Beacon uses): 8 * - POST /_matrix/client/r0/login - authenticate 9 * - GET /_matrix/client/r0/sync - poll for events (long polling) 10 * - POST /_matrix/client/r0/createRoom - create rooms 11 * - POST /_matrix/client/r0/rooms/{roomId}/join - join rooms 12 * - PUT /_matrix/client/r0/rooms/{roomId}/send/m.room.message/{txnId} - send messages 13 */ 14 15import { promises as fs } from "fs"; 16import { join } from "path"; 17import { generateKeyPairFromSeed, sign, convertPublicKeyToX25519, convertSecretKeyToX25519 } from "@stablelib/ed25519"; 18import { clientSessionKeys, serverSessionKeys } from "@stablelib/x25519-session"; 19import { hash } from "@stablelib/blake2b"; 20import { encode } from "@stablelib/utf8"; 21import { secretBox, openSecretBox } from "@stablelib/nacl"; 22import { randomBytes } from "@stablelib/random"; 23import sodium from "libsodium-wrappers"; 24import bs58check from "bs58check"; 25import qrcode from "qrcode-terminal"; 26import { getPkhfromPk } from "@taquito/utils"; 27 28// Secretbox constants (same as Beacon SDK) 29const secretbox_NONCEBYTES = 24; 30const secretbox_MACBYTES = 16; 31 32// Storage file for Node.js 33const BEACON_STORAGE_FILE = join(process.env.HOME, ".ac-beacon-storage.json"); 34 35// Matrix API base path 36const MATRIX_API = "/_matrix/client/r0"; 37 38// Wait for sodium to initialize 39const sodiumReady = sodium.ready; 40 41// Terminal colors 42const RESET = '\x1b[0m'; 43const BOLD = '\x1b[1m'; 44const DIM = '\x1b[2m'; 45const CYAN = '\x1b[36m'; 46const GREEN = '\x1b[32m'; 47const YELLOW = '\x1b[33m'; 48const RED = '\x1b[31m'; 49 50/** 51 * Beacon Serializer - bs58check encode/decode JSON (matches @airgap/beacon-core) 52 */ 53class Serializer { 54 async serialize(message) { 55 const str = JSON.stringify(message); 56 return bs58check.encode(Buffer.from(str)); 57 } 58 59 async deserialize(encoded) { 60 const decodedBytes = bs58check.decode(encoded); 61 const jsonString = Buffer.from(decodedBytes).toString('utf8'); 62 return JSON.parse(jsonString); 63 } 64} 65 66/** 67 * Encrypt message with symmetric key (matches encryptCryptoboxPayload) 68 */ 69function encryptCryptoboxPayload(message, sharedKey) { 70 const nonce = randomBytes(secretbox_NONCEBYTES); 71 const messageBytes = Buffer.from(message, 'utf8'); 72 const ciphertext = secretBox(sharedKey, nonce, messageBytes); 73 74 const combined = Buffer.concat([Buffer.from(nonce), Buffer.from(ciphertext)]); 75 return toHex(combined); 76} 77 78/** 79 * Convert bytes to hex string 80 */ 81function toHex(bytes) { 82 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); 83} 84 85/** 86 * Convert hex string to Buffer 87 */ 88function fromHex(hex) { 89 return Buffer.from(hex, 'hex'); 90} 91 92/** 93 * Get BLAKE2b hash of public key (32 bytes) as hex - matches Beacon SDK getHexHash 94 */ 95function getPublicKeyHash(publicKey) { 96 const hashed = hash(publicKey, 32); 97 return toHex(hashed); 98} 99 100/** 101 * Matrix HTTP Client - direct port from Beacon SDK 102 */ 103class MatrixClient { 104 constructor(baseUrl) { 105 this.baseUrl = baseUrl; 106 this.accessToken = null; 107 this.syncToken = null; 108 this.txnNo = 0; 109 } 110 111 async request(method, endpoint, body = null, useAuth = true) { 112 const url = `${this.baseUrl}${MATRIX_API}${endpoint}`; 113 const headers = { 'Content-Type': 'application/json' }; 114 if (useAuth && this.accessToken) { 115 headers['Authorization'] = `Bearer ${this.accessToken}`; 116 } 117 118 const options = { method, headers }; 119 if (body) options.body = JSON.stringify(body); 120 121 const response = await fetch(url, options); 122 if (!response.ok) { 123 const error = await response.json().catch(() => ({})); 124 throw new Error(`Matrix API error: ${response.status} - ${error.error || response.statusText}`); 125 } 126 return response.json(); 127 } 128 129 /** 130 * Login to Matrix server using Beacon's signature-based auth 131 */ 132 async login(userId, password, deviceId) { 133 const result = await this.request('POST', '/login', { 134 type: 'm.login.password', 135 identifier: { type: 'm.id.user', user: userId }, 136 password, 137 device_id: deviceId 138 }, false); 139 140 this.accessToken = result.access_token; 141 return result; 142 } 143 144 /** 145 * Long-poll for sync events 146 */ 147 async sync(timeout = 30000) { 148 let endpoint = '/sync'; 149 const params = new URLSearchParams(); 150 if (this.syncToken) params.set('since', this.syncToken); 151 if (timeout) params.set('timeout', timeout.toString()); 152 if (params.toString()) endpoint += '?' + params.toString(); 153 154 const result = await this.request('GET', endpoint); 155 this.syncToken = result.next_batch; 156 return result; 157 } 158 159 /** 160 * Join a room 161 */ 162 async joinRoom(roomId) { 163 return this.request('POST', `/rooms/${encodeURIComponent(roomId)}/join`, {}); 164 } 165 166 /** 167 * Send a text message to a room 168 */ 169 async sendMessage(roomId, message) { 170 const txnId = `m${Date.now()}.${this.txnNo++}`; 171 return this.request('PUT', `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, { 172 msgtype: 'm.text', 173 body: message 174 }); 175 } 176} 177 178/** 179 * Beacon P2P Client - handles pairing and message encryption 180 */ 181class BeaconP2PClient { 182 constructor(relayServer = "beacon-node-1.diamond.papers.tech") { 183 this.relayServer = relayServer; 184 this.matrix = new MatrixClient(`https://${relayServer}`); 185 this.keyPair = null; 186 this.pairingRequest = null; 187 } 188 189 /** 190 * Initialize with a new keypair 191 */ 192 async init() { 193 await sodiumReady; 194 195 // Generate keypair from random seed 196 const seed = crypto.randomUUID(); 197 const seedHash = hash(encode(seed), 32); 198 this.keyPair = generateKeyPairFromSeed(seedHash); 199 200 return this; 201 } 202 203 /** 204 * Get public key as hex string 205 */ 206 getPublicKey() { 207 return toHex(this.keyPair.publicKey); 208 } 209 210 /** 211 * Get public key hash (for Matrix user ID) 212 */ 213 getPublicKeyHash() { 214 return getPublicKeyHash(this.keyPair.publicKey); 215 } 216 217 /** 218 * Generate pairing request for QR code 219 */ 220 generatePairingRequest(appName = "Aesthetic Computer") { 221 const publicKey = this.getPublicKey(); 222 const id = crypto.randomUUID(); 223 224 this.pairingRequest = { 225 type: "p2p-pairing-request", 226 id, 227 name: appName, 228 publicKey, 229 version: "3", 230 relayServer: this.relayServer 231 }; 232 233 const serialized = bs58check.encode(Buffer.from(JSON.stringify(this.pairingRequest))); 234 const deepLinkUrl = `tezos://?type=tzip10&data=${serialized}`; 235 236 return { pairingRequest: this.pairingRequest, serialized, deepLinkUrl }; 237 } 238 239 /** 240 * Connect to Matrix relay server 241 * Uses Beacon's signature-based authentication 242 */ 243 async connect() { 244 // Get relay server timestamp for login (Beacon uses /_synapse/client/beacon/info) 245 const infoUrl = `https://${this.relayServer}/_synapse/client/beacon/info`; 246 const info = await fetch(infoUrl).then(r => r.json()); 247 248 // Beacon SDK: const time = Math.floor(relayServer.timestamp) 249 // const loginString = `login:${Math.floor(time / (5 * 60))}` 250 const time = Math.floor(info.timestamp); 251 const loginBucket = Math.floor(time / (5 * 60)); 252 const loginString = `login:${loginBucket}`; 253 254 console.log(`${DIM}Login bucket: ${loginBucket} (timestamp: ${time})${RESET}`); 255 256 // Create login signature (same as Beacon SDK) 257 const loginDigest = hash(encode(loginString), 32); 258 const signature = sign(this.keyPair.secretKey, loginDigest); 259 260 // Login with signature 261 const userId = this.getPublicKeyHash(); 262 const password = `ed:${toHex(signature)}:${this.getPublicKey()}`; 263 const deviceId = toHex(this.keyPair.publicKey); 264 265 console.log(`${DIM}Logging in as @${userId}:${this.relayServer}...${RESET}`); 266 await this.matrix.login(userId, password, deviceId); 267 console.log(`${GREEN}${RESET} Connected to Matrix relay`); 268 269 return this; 270 } 271 272 /** 273 * Wait for wallet pairing response 274 */ 275 async waitForPairing(timeoutMs = 120000) { 276 console.log(`${YELLOW}⏳ Waiting for wallet to connect...${RESET}`); 277 278 const startTime = Date.now(); 279 280 while (Date.now() - startTime < timeoutMs) { 281 try { 282 // Poll for new events (5 second timeout for responsiveness) 283 const syncResult = await this.matrix.sync(5000); 284 285 // Check for room invites 286 if (syncResult.rooms?.invite) { 287 for (const [roomId, room] of Object.entries(syncResult.rooms.invite)) { 288 console.log(`${GREEN}${RESET} Received room invite: ${roomId.slice(0, 20)}...`); 289 await this.matrix.joinRoom(roomId); 290 console.log(`${GREEN}${RESET} Joined room`); 291 } 292 } 293 294 // Check for messages in joined rooms 295 if (syncResult.rooms?.join) { 296 for (const [roomId, room] of Object.entries(syncResult.rooms.join)) { 297 const events = room.timeline?.events || []; 298 for (const event of events) { 299 if (event.type === 'm.room.message' && event.content?.body) { 300 const message = event.content.body; 301 302 // Check for channel-open message (pairing response) 303 if (message.startsWith('@channel-open:')) { 304 console.log(`${GREEN}${RESET} Received pairing response!`); 305 const pairingResponse = await this.decryptPairingResponse(message, event.sender, roomId); 306 return pairingResponse; 307 } 308 } 309 } 310 } 311 } 312 } catch (err) { 313 // Sync timeout is normal, continue polling 314 if (!err.message.includes('timeout')) { 315 console.log(`${DIM}Sync: ${err.message}${RESET}`); 316 } 317 } 318 } 319 320 throw new Error("Pairing timeout - no wallet connected"); 321 } 322 323 /** 324 * Decrypt pairing response from wallet using openCryptobox 325 * The message is encrypted with sealCryptobox (asymmetric encryption) 326 */ 327 async decryptPairingResponse(message, sender, roomId) { 328 // Message format: @channel-open:@<recipientHash>:<relayServer>:<encryptedHex> 329 const splits = message.split(':'); 330 const encryptedHex = splits[splits.length - 1]; 331 const encrypted = fromHex(encryptedHex); 332 333 // Extract sender info 334 const senderHash = sender.split(':')[0].slice(1); // Remove @ prefix 335 336 console.log(`${DIM}Decrypting pairing response...${RESET}`); 337 338 try { 339 // Use openCryptobox - the Beacon SDK's asymmetric decryption 340 // First 32 bytes are ephemeral public key, rest is ciphertext 341 const epk = encrypted.slice(0, 32); 342 const ciphertext = encrypted.slice(32); 343 344 // Convert our ed25519 keys to x25519 for decryption 345 const kxSecretKey = sodium.crypto_sign_ed25519_sk_to_curve25519(this.keyPair.secretKey); 346 const kxPublicKey = sodium.crypto_sign_ed25519_pk_to_curve25519(this.keyPair.publicKey); 347 348 // Create nonce from BLAKE2b(epk || kxPublicKey) 349 const nonceData = new Uint8Array(64); 350 nonceData.set(epk, 0); 351 nonceData.set(kxPublicKey, 32); 352 const nonce = hash(nonceData, 24); 353 354 // Decrypt using crypto_box_open (nacl box) 355 const decrypted = sodium.crypto_box_open_easy(ciphertext, nonce, epk, kxSecretKey); 356 357 // Parse the JSON pairing response 358 const jsonStr = new TextDecoder().decode(decrypted); 359 const pairingResponse = JSON.parse(jsonStr); 360 361 console.log(`${GREEN}${RESET} Decrypted pairing response:`); 362 console.log(` ${DIM}Name:${RESET} ${pairingResponse.name}`); 363 console.log(` ${DIM}Public Key:${RESET} ${pairingResponse.publicKey?.slice(0, 16)}...`); 364 console.log(` ${DIM}Relay Server:${RESET} ${pairingResponse.relayServer}`); 365 366 // Store the peer info for future communication 367 this.peer = { 368 ...pairingResponse, 369 senderId: sender, 370 roomId 371 }; 372 373 // Note: The DApp does NOT send a pairing response back. 374 // The wallet considers pairing complete after sending its response. 375 // Temple may show "loading" until we send an actual request (like PermissionRequest). 376 377 return pairingResponse; 378 } catch (err) { 379 console.log(`${YELLOW}Decryption failed: ${err.message}${RESET}`); 380 // Return basic info even if decryption fails 381 return { sender, senderHash, error: err.message }; 382 } 383 } 384 385 /** 386 * Send our pairing response back to the wallet 387 * This completes the handshake so the wallet knows we received their response 388 */ 389 async sendPairingResponse(walletPairingResponse, roomId) { 390 console.log(`${DIM}Sending pairing response back to wallet...${RESET}`); 391 392 try { 393 // Our response (same structure as what wallet sent) 394 const ourResponse = { 395 id: this.pairingRequest.id, 396 type: "p2p-pairing-response", 397 name: this.pairingRequest.name, 398 publicKey: this.getPublicKey(), 399 version: this.pairingRequest.version, 400 relayServer: this.relayServer 401 }; 402 403 // Encrypt for wallet using sealCryptobox 404 const message = JSON.stringify(ourResponse); 405 const encryptedMessage = await this.encryptForPeer(message, walletPairingResponse.publicKey); 406 407 // Build recipient string for the wallet 408 const walletPubKeyHash = getPublicKeyHash(fromHex(walletPairingResponse.publicKey)); 409 const recipient = `@${walletPubKeyHash}:${walletPairingResponse.relayServer}`; 410 411 // Format: @channel-open:<recipient>:<encryptedHex> 412 const channelOpenMsg = `@channel-open:${recipient}:${encryptedMessage}`; 413 414 // Send to the room 415 await this.matrix.sendMessage(roomId, channelOpenMsg); 416 console.log(`${GREEN}${RESET} Sent pairing response to wallet`); 417 418 } catch (err) { 419 console.log(`${YELLOW}Warning: Could not send pairing response: ${err.message}${RESET}`); 420 } 421 } 422 423 /** 424 * Encrypt a message for a peer using sealCryptobox (asymmetric) 425 */ 426 async encryptForPeer(message, peerPublicKeyHex) { 427 const peerPublicKey = fromHex(peerPublicKeyHex); 428 429 // Convert peer's ed25519 public key to x25519 430 const peerX25519PublicKey = sodium.crypto_sign_ed25519_pk_to_curve25519(peerPublicKey); 431 432 // Generate ephemeral keypair for this message 433 const ephemeralKeyPair = sodium.crypto_box_keypair(); 434 435 // Create nonce from BLAKE2b(ephemeralPublicKey || peerX25519PublicKey) 436 const nonceData = new Uint8Array(64); 437 nonceData.set(ephemeralKeyPair.publicKey, 0); 438 nonceData.set(peerX25519PublicKey, 32); 439 const nonce = hash(nonceData, 24); 440 441 // Encrypt 442 const messageBytes = encode(message); 443 const ciphertext = sodium.crypto_box_easy(messageBytes, nonce, peerX25519PublicKey, ephemeralKeyPair.privateKey); 444 445 // Prepend ephemeral public key to ciphertext 446 const sealed = new Uint8Array(32 + ciphertext.length); 447 sealed.set(ephemeralKeyPair.publicKey, 0); 448 sealed.set(ciphertext, 32); 449 450 return toHex(sealed); 451 } 452 453 /** 454 * Send a permission request to the wallet 455 * This is required after pairing to actually get wallet access 456 */ 457 async sendPermissionRequest() { 458 if (!this.peer) throw new Error("Not paired with wallet yet"); 459 460 console.log(`${DIM}Sending permission request to wallet...${RESET}`); 461 462 const senderId = await this.getSenderId(); 463 464 // Build the permission request (Beacon v2 format) 465 const permissionRequest = { 466 type: "permission_request", 467 version: "2", // Use v2 for Temple compatibility 468 id: crypto.randomUUID(), 469 senderId: senderId, 470 appMetadata: { 471 senderId: senderId, 472 name: this.pairingRequest.name 473 }, 474 network: { 475 type: "ghostnet" // or "mainnet" 476 }, 477 scopes: ["operation_request", "sign"] 478 }; 479 480 console.log(`${DIM}Permission request:${RESET}`, JSON.stringify(permissionRequest, null, 2)); 481 482 // Serialize the message with bs58check (like Beacon SDK Serializer) 483 const serializer = new Serializer(); 484 const serializedMessage = await serializer.serialize(permissionRequest); 485 486 console.log(`${DIM}Serialized (bs58check): ${serializedMessage.slice(0, 50)}...${RESET}`); 487 488 // Encrypt using symmetric key derived from key exchange 489 // DApp uses clientSessionKeys, sends with sharedTx (which Beacon calls .send) 490 const encryptedMessage = await this.encryptMessageForPeer(serializedMessage); 491 492 console.log(`${DIM}Encrypted message: ${encryptedMessage.slice(0, 50)}...${RESET}`); 493 494 // Send to the room 495 await this.matrix.sendMessage(this.peer.roomId, encryptedMessage); 496 console.log(`${GREEN}${RESET} Sent permission request`); 497 498 return permissionRequest; 499 } 500 501 /** 502 * Get our sender ID (hash of public key) - matches Beacon SDK getSenderId 503 */ 504 async getSenderId() { 505 // Beacon SDK: hash(publicKey, 5) -> hex 506 const pubKeyHash = hash(this.keyPair.publicKey, 5); 507 return toHex(pubKeyHash); 508 } 509 510 /** 511 * Wait for permission response from wallet 512 */ 513 async waitForPermissionResponse(timeoutMs = 120000) { 514 console.log(`${YELLOW}⏳ Waiting for wallet approval...${RESET}`); 515 516 const startTime = Date.now(); 517 518 while (Date.now() - startTime < timeoutMs) { 519 try { 520 const syncResult = await this.matrix.sync(5000); 521 522 // Check for messages in joined rooms 523 if (syncResult.rooms?.join) { 524 for (const [roomId, room] of Object.entries(syncResult.rooms.join)) { 525 const events = room.timeline?.events || []; 526 for (const event of events) { 527 if (event.type === 'm.room.message' && event.content?.body) { 528 const message = event.content.body; 529 530 // Skip channel-open messages (those are for pairing) 531 if (message.startsWith('@channel-open:')) continue; 532 533 // Try to decrypt as a permission response 534 try { 535 const decrypted = await this.decryptMessageFromPeer(message); 536 if (decrypted) { 537 // decrypted is already parsed (deserialize returns parsed JSON) 538 const parsed = typeof decrypted === 'string' ? JSON.parse(decrypted) : decrypted; 539 console.log(`${GREEN}${RESET} Received message from wallet:`, parsed.type); 540 541 if (parsed.type === 'permission_response') { 542 return parsed; 543 } else if (parsed.type === 'acknowledge') { 544 console.log(`${DIM}Received acknowledge message${RESET}`); 545 } else if (parsed.type === 'disconnect') { 546 throw new Error('Wallet rejected/disconnected'); 547 } else if (parsed.type === 'error') { 548 throw new Error(`Wallet error: ${parsed.errorType || parsed.message}`); 549 } 550 } 551 } catch (decryptErr) { 552 // Not a message we can decrypt, or parse error 553 if (!decryptErr.message.includes('decrypt') && !decryptErr.message.includes('Payload too short')) { 554 console.log(`${DIM}Message parsing: ${decryptErr.message}${RESET}`); 555 } 556 } 557 } 558 } 559 } 560 } 561 } catch (err) { 562 if (!err.message.includes('timeout')) { 563 console.log(`${DIM}Sync: ${err.message}${RESET}`); 564 } 565 } 566 } 567 568 throw new Error("Permission request timeout - wallet did not respond"); 569 } 570 571 /** 572 * Decrypt a message from the wallet using symmetric encryption 573 * Wallet uses serverSessionKeys to send (so we receive with client's receive key) 574 * But Beacon SDK: DApp creates CryptoBoxServer to receive from wallet! 575 */ 576 async decryptMessageFromPeer(encryptedHex) { 577 const encrypted = fromHex(encryptedHex); 578 const peerPublicKey = fromHex(this.peer.publicKey); 579 580 // Check minimum payload length (nonce + mac) 581 if (encrypted.length < secretbox_NONCEBYTES + secretbox_MACBYTES) { 582 throw new Error('Payload too short'); 583 } 584 585 // Convert keys to x25519 586 const myX25519Public = convertPublicKeyToX25519(this.keyPair.publicKey); 587 const myX25519Secret = convertSecretKeyToX25519(this.keyPair.secretKey); 588 const peerX25519Public = convertPublicKeyToX25519(peerPublicKey); 589 590 // Beacon SDK: DApp uses createCryptoBoxServer to RECEIVE messages 591 // (opposite of sending where we use createCryptoBoxClient) 592 const sessionKeys = serverSessionKeys( 593 { publicKey: myX25519Public, secretKey: myX25519Secret }, 594 peerX25519Public 595 ); 596 const sharedKey = sessionKeys.receive; 597 598 // Extract nonce (first 24 bytes) and ciphertext 599 const nonce = encrypted.slice(0, secretbox_NONCEBYTES); 600 const ciphertext = encrypted.slice(secretbox_NONCEBYTES); 601 602 // Decrypt 603 const decrypted = openSecretBox(sharedKey, nonce, ciphertext); 604 if (!decrypted) { 605 throw new Error('Decryption failed'); 606 } 607 608 // The decrypted content is a serialized (bs58check) string 609 const serializedStr = new TextDecoder().decode(decrypted); 610 611 // Deserialize from bs58check 612 const serializer = new Serializer(); 613 return await serializer.deserialize(serializedStr); 614 } 615 616 /** 617 * Send an operation request to the wallet (e.g., contract call) 618 * @param {Object} operationDetails - The operation to execute 619 * @param {string} operationDetails.kind - "transaction", "origination", etc. 620 * @param {string} operationDetails.destination - Contract address 621 * @param {string} operationDetails.amount - Amount in mutez (string) 622 * @param {Object} operationDetails.parameters - Michelson parameters 623 * @param {string} operationDetails.fee - Fee in mutez (optional) 624 * @param {string} operationDetails.gasLimit - Gas limit (optional) 625 * @param {string} operationDetails.storageLimit - Storage limit (optional) 626 */ 627 async sendOperationRequest(operationDetails) { 628 console.log(`${DIM}Sending operation request to wallet...${RESET}`); 629 630 const senderId = await this.getSenderId(); 631 632 // Tezos operations use snake_case: gas_limit, storage_limit 633 // Temple iOS has issues estimating, so we provide generous defaults 634 const ops = Array.isArray(operationDetails) ? operationDetails : [operationDetails]; 635 const normalizedOps = ops.map(op => { 636 const cleanOp = { ...op }; 637 // Remove any camelCase variants 638 delete cleanOp.gasLimit; 639 delete cleanOp.storageLimit; 640 // Set snake_case values with generous estimates for contract calls 641 // These are based on typical FA2 minting operations 642 cleanOp.fee = op.fee || "200000"; // 0.2 XTZ 643 cleanOp.gas_limit = op.gas_limit || "50000"; // 50k gas 644 cleanOp.storage_limit = op.storage_limit || "5000"; // 5k storage bytes 645 return cleanOp; 646 }); 647 648 // Build the operation request (Beacon v2 format) 649 const operationRequest = { 650 type: "operation_request", 651 version: "2", 652 id: crypto.randomUUID(), 653 senderId: senderId, 654 network: { 655 type: "ghostnet", 656 rpcUrl: "https://ghostnet.ecadinfra.com" 657 }, 658 operationDetails: normalizedOps, 659 sourceAddress: this.permissionResponse?.address || this.permissionResponse?.publicKey 660 }; 661 662 console.log(`${DIM}Operation request:${RESET}`, JSON.stringify(operationRequest, null, 2)); 663 664 // Serialize the message 665 const serializer = new Serializer(); 666 const serializedMessage = await serializer.serialize(operationRequest); 667 668 // Encrypt using symmetric key derived from key exchange 669 const encryptedMessage = await this.encryptMessageForPeer(serializedMessage); 670 671 // Send to the room 672 await this.matrix.sendMessage(this.peer.roomId, encryptedMessage); 673 console.log(`${GREEN}${RESET} Sent operation request`); 674 675 return operationRequest; 676 } 677 678 /** 679 * Wait for operation response from wallet 680 */ 681 async waitForOperationResponse(timeoutMs = 120000) { 682 console.log(`${YELLOW}⏳ Waiting for wallet to sign operation...${RESET}`); 683 684 const startTime = Date.now(); 685 686 while (Date.now() - startTime < timeoutMs) { 687 try { 688 const syncResult = await this.matrix.sync(5000); 689 690 // Check for messages in joined rooms 691 if (syncResult.rooms?.join) { 692 for (const [roomId, room] of Object.entries(syncResult.rooms.join)) { 693 const events = room.timeline?.events || []; 694 for (const event of events) { 695 if (event.type === 'm.room.message' && event.content?.body) { 696 const message = event.content.body; 697 698 // Skip channel-open messages 699 if (message.startsWith('@channel-open:')) continue; 700 701 try { 702 const decrypted = await this.decryptMessageFromPeer(message); 703 if (decrypted) { 704 const parsed = typeof decrypted === 'string' ? JSON.parse(decrypted) : decrypted; 705 console.log(`${GREEN}${RESET} Received message from wallet:`, parsed.type); 706 707 if (parsed.type === 'operation_response') { 708 return parsed; 709 } else if (parsed.type === 'acknowledge') { 710 console.log(`${DIM}Received acknowledge message${RESET}`); 711 } else if (parsed.type === 'error') { 712 throw new Error(`Wallet error: ${parsed.errorType || parsed.message || 'Operation rejected'}`); 713 } 714 } 715 } catch (decryptErr) { 716 if (!decryptErr.message.includes('decrypt') && !decryptErr.message.includes('Payload too short')) { 717 console.log(`${DIM}Message parsing: ${decryptErr.message}${RESET}`); 718 } 719 } 720 } 721 } 722 } 723 } 724 } catch (err) { 725 if (!err.message.includes('timeout')) { 726 console.log(`${DIM}Sync: ${err.message}${RESET}`); 727 } 728 } 729 } 730 731 throw new Error("Operation request timeout - wallet did not respond"); 732 } 733 734 /** 735 * Encrypt a message for the peer using symmetric encryption (after pairing) 736 * Uses shared secret derived from X25519 key exchange 737 * Matches Beacon SDK: createCryptoBoxClient + encryptCryptoboxPayload 738 */ 739 async encryptMessageForPeer(message) { 740 const peerPublicKey = fromHex(this.peer.publicKey); 741 742 // Convert ed25519 keys to x25519 using @stablelib 743 const myX25519Public = convertPublicKeyToX25519(this.keyPair.publicKey); 744 const myX25519Secret = convertSecretKeyToX25519(this.keyPair.secretKey); 745 const peerX25519Public = convertPublicKeyToX25519(peerPublicKey); 746 747 // Derive shared key using clientSessionKeys (DApp is client, wallet is server) 748 // This matches Beacon SDK's createCryptoBoxClient 749 const sessionKeys = clientSessionKeys( 750 { publicKey: myX25519Public, secretKey: myX25519Secret }, 751 peerX25519Public 752 ); 753 754 // Use .send for sending (DApp sends with client's send key) 755 const sharedKey = sessionKeys.send; 756 757 console.log(`${DIM}Shared key (first 8 bytes): ${toHex(sharedKey.slice(0, 8))}...${RESET}`); 758 759 // Encrypt with secretbox (nonce || ciphertext) 760 return encryptCryptoboxPayload(message, sharedKey); 761 } 762} 763 764/** 765 * Display QR code in terminal 766 */ 767export function displayQR(data, small = true) { 768 console.log(`\n${BOLD}${CYAN}╔════════════════════════════════════════════════════════════════╗${RESET}`); 769 console.log(`${BOLD}${CYAN}║ 📱 Scan with Temple Wallet (Beacon P2P) ║${RESET}`); 770 console.log(`${BOLD}${CYAN}╚════════════════════════════════════════════════════════════════╝${RESET}\n`); 771 772 // Generate QR synchronously to stdout 773 qrcode.generate(data, { small }); 774 775 console.log(`\n\n${DIM}Waiting for wallet connection...${RESET}\n\n`); 776} 777 778/** 779 * Full P2P pairing flow 780 */ 781export async function pairWallet(appName = "Aesthetic Computer") { 782 console.log(`\n${BOLD}${CYAN}═══════════════════════════════════════════════════════════════════${RESET}`); 783 console.log(`${BOLD}${CYAN} Beacon P2P Wallet Pairing ${RESET}`); 784 console.log(`${BOLD}${CYAN}═══════════════════════════════════════════════════════════════════${RESET}\n`); 785 786 // Create P2P client 787 const client = await new BeaconP2PClient().init(); 788 789 // Generate pairing request 790 const { pairingRequest, serialized, deepLinkUrl } = client.generatePairingRequest(appName); 791 792 console.log(`${GREEN}${RESET} Generated pairing request:`); 793 console.log(` ${DIM}ID:${RESET} ${pairingRequest.id}`); 794 console.log(` ${DIM}Name:${RESET} ${pairingRequest.name}`); 795 console.log(` ${DIM}Public Key:${RESET} ${pairingRequest.publicKey.slice(0, 16)}...`); 796 console.log(` ${DIM}Relay:${RESET} ${pairingRequest.relayServer}`); 797 798 // Display QR code 799 displayQR(deepLinkUrl); 800 801 // Connect to Matrix relay 802 await client.connect(); 803 804 // Wait for wallet to respond 805 const response = await client.waitForPairing(120000); 806 807 if (response) { 808 console.log(`\n${GREEN}✓ Wallet paired successfully!${RESET}`); 809 console.log(` ${DIM}Name:${RESET} ${response.name}`); 810 console.log(` ${DIM}Public Key:${RESET} ${response.publicKey?.slice(0, 16)}...`); 811 812 // Send permission request to complete the flow 813 // This makes the wallet show the permission approval screen 814 await client.sendPermissionRequest(); 815 816 // Wait for user to approve in wallet 817 try { 818 const permissionResponse = await client.waitForPermissionResponse(120000); 819 console.log(`\n${GREEN}✓ Permission granted!${RESET}`); 820 821 // Debug: show full response structure 822 console.log(`${DIM}Full response:${RESET}`, JSON.stringify(permissionResponse, null, 2)); 823 824 // Get public key from response 825 const publicKey = permissionResponse.publicKey || 826 permissionResponse.account?.publicKey || 827 permissionResponse.accountInfo?.publicKey; 828 829 // Address might be in response, or derive from public key 830 let address = permissionResponse.address || 831 permissionResponse.account?.address || 832 permissionResponse.accountInfo?.address; 833 834 // Derive address from public key if not provided directly 835 if (!address && publicKey) { 836 try { 837 address = getPkhfromPk(publicKey); 838 console.log(`${DIM}(Address derived from public key)${RESET}`); 839 } catch (e) { 840 console.log(`${DIM}Could not derive address: ${e.message}${RESET}`); 841 } 842 } 843 844 // Store the derived address in the response for callers 845 permissionResponse.address = address; 846 847 console.log(` ${DIM}Address:${RESET} ${address || '(unknown)'}`); 848 console.log(` ${DIM}Public Key:${RESET} ${publicKey?.slice(0, 16)}...`); 849 console.log(` ${DIM}Network:${RESET} ${permissionResponse.network?.type}`); 850 console.log(` ${DIM}Scopes:${RESET} ${permissionResponse.scopes?.join(', ')}`); 851 852 // Store permission response in client for operation requests 853 client.permissionResponse = permissionResponse; 854 855 return { client, response, permissionResponse }; 856 } catch (permErr) { 857 console.log(`\n${RED}✗ Permission denied or timeout: ${permErr.message}${RESET}`); 858 return { client, response, error: permErr.message }; 859 } 860 } else { 861 console.log(`\n${RED}✗ Pairing failed${RESET}`); 862 return null; 863 } 864} 865 866/** 867 * Quick test of QR generation only (no Matrix connection) 868 */ 869export async function testQR() { 870 console.log(`\n${BOLD}Testing Beacon QR code generation...${RESET}\n`); 871 872 const client = await new BeaconP2PClient().init(); 873 const { pairingRequest, serialized, deepLinkUrl } = client.generatePairingRequest(); 874 875 console.log(`${GREEN}${RESET} Generated pairing request:`); 876 console.log(` ID: ${pairingRequest.id}`); 877 console.log(` Name: ${pairingRequest.name}`); 878 console.log(` Public Key: ${pairingRequest.publicKey.slice(0, 16)}...`); 879 console.log(` Version: ${pairingRequest.version}`); 880 console.log(` Relay: ${pairingRequest.relayServer}`); 881 console.log(` Serialized: ${serialized.slice(0, 30)}...`); 882 console.log(` Length: ${serialized.length} chars`); 883 console.log(`\n${CYAN}Deep Link URL for QR:${RESET}`); 884 console.log(` ${deepLinkUrl.slice(0, 60)}...\n`); 885 886 // Display QR with deep link URL 887 displayQR(deepLinkUrl); 888 889 return { pairingRequest, serialized, deepLinkUrl }; 890} 891 892/** 893 * Send a contract call operation via a connected Beacon client 894 * @param {BeaconP2PClient} client - Connected Beacon client 895 * @param {string} contractAddress - Contract to call 896 * @param {string} entrypoint - Entrypoint name 897 * @param {Object} args - Michelson arguments 898 * @param {string} amount - Amount in XTZ (optional, default "0") 899 */ 900export async function sendContractCall(client, contractAddress, entrypoint, args, amountMutez = "0") { 901 if (!client.permissionResponse?.address) { 902 throw new Error("Wallet not connected - run pairWallet first"); 903 } 904 905 // Amount is already in mutez 906 const amountStr = String(amountMutez); 907 const amountXtz = (parseFloat(amountStr) / 1_000_000).toFixed(2); 908 909 console.log(`\n${BOLD}${CYAN}═══════════════════════════════════════════════════════════════════${RESET}`); 910 console.log(`${BOLD}${CYAN} Sending Contract Call via Temple ${RESET}`); 911 console.log(`${BOLD}${CYAN}═══════════════════════════════════════════════════════════════════${RESET}\n`); 912 913 console.log(`${DIM}Contract:${RESET} ${contractAddress}`); 914 console.log(`${DIM}Entrypoint:${RESET} ${entrypoint}`); 915 console.log(`${DIM}Amount:${RESET} ${amountXtz} ꜩ (${amountStr} mutez)`); 916 console.log(`${DIM}Sender:${RESET} ${client.permissionResponse.address}`); 917 918 const operation = { 919 kind: "transaction", 920 destination: contractAddress, 921 amount: amountStr, 922 parameters: { 923 entrypoint: entrypoint, 924 value: args 925 } 926 }; 927 928 // Send operation request 929 await client.sendOperationRequest(operation); 930 931 // Wait for response 932 const response = await client.waitForOperationResponse(120000); 933 934 if (response.transactionHash) { 935 console.log(`\n${GREEN}✓ Transaction confirmed!${RESET}`); 936 console.log(`${DIM}Operation hash:${RESET} ${response.transactionHash}`); 937 return response; 938 } else { 939 console.log(`\n${GREEN}✓ Operation response received${RESET}`); 940 console.log(`${DIM}Response:${RESET}`, JSON.stringify(response, null, 2)); 941 return response; 942 } 943} 944 945// Export the client class 946export { BeaconP2PClient }; 947 948// Run if called directly 949if (import.meta.url === `file://${process.argv[1]}`) { 950 const args = process.argv.slice(2); 951 952 if (args.includes('--pair') || args.includes('-p')) { 953 pairWallet().catch(err => { 954 console.error(`${RED}Error: ${err.message}${RESET}`); 955 process.exit(1); 956 }); 957 } else { 958 testQR().catch(console.error); 959 } 960}