Monorepo for Aesthetic.Computer
aesthetic.computer
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}