Monorepo for Aesthetic.Computer
aesthetic.computer
1// Keeps Wallet - Background Service Worker
2// Handles key management, signing, Tezos operations, and Beacon protocol
3
4import {
5 initCrypto,
6 generateMnemonic,
7 validateMnemonic,
8 deriveKeypair,
9 importFromPrivateKey,
10 encrypt,
11 decrypt,
12 signOperation as cryptoSignOperation,
13 sign as cryptoSign,
14 clearSensitiveData,
15} from './lib/crypto.mjs';
16
17import {
18 getOrCreateBeaconKeypair,
19 getSenderId,
20 encryptBeaconMessage,
21 decryptBeaconMessage,
22 bytesToHex,
23 hexToBytes,
24} from './lib/beacon-crypto.mjs';
25
26// State (in-memory, encrypted keys in storage)
27let unlockedWallet = null; // { address, publicKey, secretKeyBytes }
28let lockTimeout = null;
29const LOCK_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
30
31// Beacon pending requests — requestId -> { request, peerPublicKey, senderTabId, resolve }
32const pendingBeaconRequests = new Map();
33
34// Initialize crypto on load
35initCrypto().then(() => {
36 console.log('Keeps Wallet crypto initialized');
37});
38
39// Prefer opening the UI in the side panel (new extension UX)
40async function enableSidePanel() {
41 if (!chrome.sidePanel?.setPanelBehavior) return;
42 try {
43 await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
44 await chrome.sidePanel.setOptions({
45 path: chrome.runtime.getURL('popup/popup.html'),
46 enabled: true,
47 });
48 } catch (err) {
49 console.warn('Side panel setup failed', err);
50 }
51}
52
53// Open UI via side panel when available; otherwise fall back to popup window.
54async function openUiForClick(tab) {
55 if (chrome.sidePanel?.open) {
56 try {
57 await chrome.sidePanel.open({ windowId: tab?.windowId });
58 return;
59 } catch (err) {
60 console.warn('Side panel open failed, falling back to popup window', err);
61 }
62 }
63
64 // Fallback: open small popup window with the same UI
65 chrome.windows.create({
66 url: chrome.runtime.getURL('popup/popup.html'),
67 type: 'popup',
68 width: 420,
69 height: 640,
70 });
71}
72
73chrome.runtime.onInstalled.addListener(() => {
74 enableSidePanel();
75});
76
77chrome.runtime.onStartup?.addListener(() => {
78 enableSidePanel();
79});
80
81// Open UI on browser action click
82chrome.action.onClicked.addListener((tab) => {
83 openUiForClick(tab);
84});
85
86// Message handler
87chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
88 handleMessage(message, sender).then(sendResponse);
89 return true; // Keep channel open for async response
90});
91
92async function handleMessage(message, sender) {
93 const { type, payload } = message;
94
95 try {
96 switch (type) {
97 case 'KEEPS_PING':
98 return { success: true, version: '0.1.0' };
99
100 case 'KEEPS_GET_STATE':
101 return await getWalletState();
102
103 case 'KEEPS_CREATE_WALLET':
104 return await createWallet(payload.password);
105
106 case 'KEEPS_IMPORT_WALLET':
107 return await importWallet(payload.mnemonic, payload.password);
108
109 case 'KEEPS_IMPORT_PRIVATE_KEY':
110 return await importPrivateKey(payload.privateKey, payload.password);
111
112 case 'KEEPS_UNLOCK':
113 return await unlockWallet(payload.password);
114
115 case 'KEEPS_LOCK':
116 return lockWallet();
117
118 case 'KEEPS_GET_ADDRESS':
119 return getAddress();
120
121 case 'KEEPS_SIGN_OPERATION':
122 return await handleSignRequest(payload.operation, sender);
123
124 case 'KEEPS_GET_BALANCE':
125 return await getBalance(payload.network);
126
127 case 'KEEPS_GET_KEEPS':
128 return await getKeeps(payload.network);
129
130 case 'KEEPS_SET_NETWORK':
131 return await setNetwork(payload.network);
132
133 // --- Beacon protocol ---
134 case 'BEACON_MESSAGE':
135 return await handleBeaconMessage(payload, sender);
136
137 case 'BEACON_CONFIRM':
138 return await handleBeaconConfirm(payload);
139
140 case 'BEACON_GET_PENDING':
141 return await handleBeaconGetPending(payload);
142
143 default:
144 return { error: 'Unknown message type' };
145 }
146 } catch (error) {
147 console.error('Message handler error:', error);
148 return { error: error.message };
149 }
150}
151
152// ============================================================
153// Beacon Protocol
154// ============================================================
155
156async function handleBeaconMessage(msgFromContent, sender) {
157 // msgFromContent is the raw postMessage data from the page
158 // It has: { target: "toExtension", payload: ..., encryptedPayload?: ..., targetId?: ... }
159 const data = msgFromContent;
160
161 // --- Pairing request (unencrypted, has publicKey + name + type fields in payload) ---
162 if (
163 data.payload &&
164 typeof data.payload === 'object' &&
165 data.payload.publicKey &&
166 data.payload.name
167 ) {
168 return await handleBeaconPairing(data.payload, sender);
169 }
170
171 // --- Encrypted message ---
172 if (data.encryptedPayload) {
173 return await handleBeaconEncryptedMessage(
174 data.encryptedPayload,
175 sender,
176 );
177 }
178
179 // Unknown Beacon message shape — ignore
180 return null;
181}
182
183async function handleBeaconPairing(pairingRequest, sender) {
184 // pairingRequest: { id, name, publicKey (hex), version, icon?, appUrl?, type? }
185 const beaconKp = await getOrCreateBeaconKeypair();
186 const ourPublicKeyHex = bytesToHex(beaconKp.publicKey);
187 const ourSenderId = await getSenderId(beaconKp.publicKey);
188
189 // Store dApp as a peer
190 const peerPublicKeyBytes = hexToBytes(pairingRequest.publicKey);
191 const peerSenderId = await getSenderId(peerPublicKeyBytes);
192
193 const peerData = {
194 name: pairingRequest.name,
195 publicKey: pairingRequest.publicKey,
196 senderId: peerSenderId,
197 version: pairingRequest.version || '2',
198 icon: pairingRequest.icon || null,
199 appUrl: pairingRequest.appUrl || null,
200 connectedAt: Date.now(),
201 };
202
203 // Save peer
204 const stored = await chrome.storage.local.get('beacon_peers');
205 const peers = stored.beacon_peers || {};
206 peers[peerSenderId] = peerData;
207 await chrome.storage.local.set({ beacon_peers: peers });
208
209 // Build pairing response
210 const pairingResponse = {
211 target: 'toPage',
212 payload: {
213 type: 'postmessage-pairing-response',
214 id: pairingRequest.id,
215 name: 'Keeps Wallet',
216 publicKey: ourPublicKeyHex,
217 version: pairingRequest.version || '2',
218 extensionId: 'keeps-wallet',
219 senderId: ourSenderId,
220 },
221 };
222
223 return { postToPage: pairingResponse };
224}
225
226async function handleBeaconEncryptedMessage(encryptedHex, sender) {
227 const beaconKp = await getOrCreateBeaconKeypair();
228 const ourSenderId = await getSenderId(beaconKp.publicKey);
229
230 // We need to find which peer sent this. Try all known peers.
231 const stored = await chrome.storage.local.get('beacon_peers');
232 const peers = stored.beacon_peers || {};
233
234 let decryptedJson = null;
235 let matchedPeerSenderId = null;
236 let matchedPeerPublicKey = null;
237
238 for (const [peerId, peer] of Object.entries(peers)) {
239 try {
240 const peerPkBytes = hexToBytes(peer.publicKey);
241 const plaintext = await decryptBeaconMessage(
242 encryptedHex,
243 peerPkBytes,
244 beaconKp.secretKey,
245 );
246 decryptedJson = plaintext;
247 matchedPeerSenderId = peerId;
248 matchedPeerPublicKey = peer.publicKey;
249 break;
250 } catch {
251 // Wrong peer, try next
252 }
253 }
254
255 if (!decryptedJson) {
256 console.warn('Beacon: could not decrypt message from any known peer');
257 return null;
258 }
259
260 let beaconMsg;
261 try {
262 beaconMsg = JSON.parse(decryptedJson);
263 } catch {
264 console.error('Beacon: decrypted payload is not valid JSON');
265 return null;
266 }
267
268 const peerPkBytes = hexToBytes(matchedPeerPublicKey);
269
270 // Route based on Beacon message type
271 switch (beaconMsg.type) {
272 case 'permission_request':
273 return await handleBeaconPermissionRequest(
274 beaconMsg,
275 peerPkBytes,
276 matchedPeerSenderId,
277 beaconKp,
278 ourSenderId,
279 sender,
280 );
281
282 case 'operation_request':
283 return await handleBeaconOperationRequest(
284 beaconMsg,
285 peerPkBytes,
286 matchedPeerSenderId,
287 beaconKp,
288 ourSenderId,
289 sender,
290 );
291
292 case 'sign_payload_request':
293 return await handleBeaconSignPayloadRequest(
294 beaconMsg,
295 peerPkBytes,
296 matchedPeerSenderId,
297 beaconKp,
298 ourSenderId,
299 sender,
300 );
301
302 case 'disconnect':
303 return await handleBeaconDisconnect(matchedPeerSenderId);
304
305 default:
306 console.warn('Beacon: unhandled message type', beaconMsg.type);
307 return null;
308 }
309}
310
311// --- Send an encrypted Beacon response back to the page via content script ---
312
313async function sendBeaconResponseToTab(tabId, responseObj, peerPkBytes, beaconKp) {
314 const responseJson = JSON.stringify(responseObj);
315 const encryptedHex = await encryptBeaconMessage(
316 responseJson,
317 peerPkBytes,
318 beaconKp.secretKey,
319 );
320
321 chrome.tabs.sendMessage(tabId, {
322 type: 'BEACON_RESPONSE',
323 data: {
324 target: 'toPage',
325 encryptedPayload: encryptedHex,
326 },
327 });
328}
329
330// --- Send an unencrypted acknowledge immediately ---
331
332async function sendBeaconAcknowledge(tabId, requestId, ourSenderId) {
333 // Acknowledge is sent unencrypted as a postMessage
334 // Actually, per Beacon protocol, acknowledge is encrypted too.
335 // But for simplicity and compatibility, we send it encrypted.
336 // We'll handle it in the request handlers.
337}
338
339// --- Build a Beacon error response ---
340
341function buildBeaconError(requestId, errorType, ourSenderId) {
342 return {
343 type: 'error',
344 id: requestId,
345 version: '2',
346 senderId: ourSenderId,
347 errorType: errorType,
348 };
349}
350
351// --- Permission Request ---
352
353async function handleBeaconPermissionRequest(
354 request,
355 peerPkBytes,
356 peerSenderId,
357 beaconKp,
358 ourSenderId,
359 sender,
360) {
361 const tabId = sender.tab?.id;
362
363 // Send encrypted acknowledge
364 if (tabId) {
365 const ack = {
366 type: 'acknowledge',
367 id: request.id,
368 version: '2',
369 senderId: ourSenderId,
370 };
371 await sendBeaconResponseToTab(tabId, ack, peerPkBytes, beaconKp);
372 }
373
374 // Check if wallet is unlocked
375 if (!unlockedWallet) {
376 if (tabId) {
377 const err = buildBeaconError(request.id, 'NOT_GRANTED_ERROR', ourSenderId);
378 await sendBeaconResponseToTab(tabId, err, peerPkBytes, beaconKp);
379 }
380 return null;
381 }
382
383 // Store pending request and open confirmation popup
384 const requestId = request.id;
385 const stored = await chrome.storage.local.get('beacon_peers');
386 const peers = stored.beacon_peers || {};
387 const peerInfo = peers[peerSenderId] || {};
388
389 return new Promise((resolve) => {
390 pendingBeaconRequests.set(requestId, {
391 requestType: 'permission_request',
392 request,
393 peerPublicKey: bytesToHex(peerPkBytes),
394 peerSenderId,
395 peerName: peerInfo.name || 'Unknown dApp',
396 peerIcon: peerInfo.icon || null,
397 beaconKpPublicKey: bytesToHex(beaconKp.publicKey),
398 tabId,
399 ourSenderId,
400 resolve,
401 });
402
403 // Open confirmation popup
404 chrome.windows.create({
405 url: chrome.runtime.getURL(
406 `confirm.html?requestId=${encodeURIComponent(requestId)}`,
407 ),
408 type: 'popup',
409 width: 400,
410 height: 500,
411 focused: true,
412 });
413 });
414}
415
416// --- Operation Request ---
417
418async function handleBeaconOperationRequest(
419 request,
420 peerPkBytes,
421 peerSenderId,
422 beaconKp,
423 ourSenderId,
424 sender,
425) {
426 const tabId = sender.tab?.id;
427
428 // Send encrypted acknowledge
429 if (tabId) {
430 const ack = {
431 type: 'acknowledge',
432 id: request.id,
433 version: '2',
434 senderId: ourSenderId,
435 };
436 await sendBeaconResponseToTab(tabId, ack, peerPkBytes, beaconKp);
437 }
438
439 // Check if wallet is unlocked
440 if (!unlockedWallet) {
441 if (tabId) {
442 const err = buildBeaconError(request.id, 'NOT_GRANTED_ERROR', ourSenderId);
443 await sendBeaconResponseToTab(tabId, err, peerPkBytes, beaconKp);
444 }
445 return null;
446 }
447
448 const requestId = request.id;
449 const stored = await chrome.storage.local.get('beacon_peers');
450 const peers = stored.beacon_peers || {};
451 const peerInfo = peers[peerSenderId] || {};
452
453 return new Promise((resolve) => {
454 pendingBeaconRequests.set(requestId, {
455 requestType: 'operation_request',
456 request,
457 peerPublicKey: bytesToHex(peerPkBytes),
458 peerSenderId,
459 peerName: peerInfo.name || 'Unknown dApp',
460 peerIcon: peerInfo.icon || null,
461 beaconKpPublicKey: bytesToHex(beaconKp.publicKey),
462 tabId,
463 ourSenderId,
464 resolve,
465 });
466
467 chrome.windows.create({
468 url: chrome.runtime.getURL(
469 `confirm.html?requestId=${encodeURIComponent(requestId)}`,
470 ),
471 type: 'popup',
472 width: 400,
473 height: 600,
474 focused: true,
475 });
476 });
477}
478
479// --- Sign Payload Request ---
480
481async function handleBeaconSignPayloadRequest(
482 request,
483 peerPkBytes,
484 peerSenderId,
485 beaconKp,
486 ourSenderId,
487 sender,
488) {
489 const tabId = sender.tab?.id;
490
491 // Send encrypted acknowledge
492 if (tabId) {
493 const ack = {
494 type: 'acknowledge',
495 id: request.id,
496 version: '2',
497 senderId: ourSenderId,
498 };
499 await sendBeaconResponseToTab(tabId, ack, peerPkBytes, beaconKp);
500 }
501
502 // Check if wallet is unlocked
503 if (!unlockedWallet) {
504 if (tabId) {
505 const err = buildBeaconError(request.id, 'NOT_GRANTED_ERROR', ourSenderId);
506 await sendBeaconResponseToTab(tabId, err, peerPkBytes, beaconKp);
507 }
508 return null;
509 }
510
511 const requestId = request.id;
512 const stored = await chrome.storage.local.get('beacon_peers');
513 const peers = stored.beacon_peers || {};
514 const peerInfo = peers[peerSenderId] || {};
515
516 return new Promise((resolve) => {
517 pendingBeaconRequests.set(requestId, {
518 requestType: 'sign_payload_request',
519 request,
520 peerPublicKey: bytesToHex(peerPkBytes),
521 peerSenderId,
522 peerName: peerInfo.name || 'Unknown dApp',
523 peerIcon: peerInfo.icon || null,
524 beaconKpPublicKey: bytesToHex(beaconKp.publicKey),
525 tabId,
526 ourSenderId,
527 resolve,
528 });
529
530 chrome.windows.create({
531 url: chrome.runtime.getURL(
532 `confirm.html?requestId=${encodeURIComponent(requestId)}`,
533 ),
534 type: 'popup',
535 width: 400,
536 height: 500,
537 focused: true,
538 });
539 });
540}
541
542// --- Disconnect ---
543
544async function handleBeaconDisconnect(peerSenderId) {
545 const stored = await chrome.storage.local.get('beacon_peers');
546 const peers = stored.beacon_peers || {};
547 delete peers[peerSenderId];
548 await chrome.storage.local.set({ beacon_peers: peers });
549 return null;
550}
551
552// --- Get pending request details (for confirm.html) ---
553
554async function handleBeaconGetPending(payload) {
555 const { requestId } = payload;
556 const pending = pendingBeaconRequests.get(requestId);
557 if (!pending) {
558 return { error: 'No pending request found' };
559 }
560
561 return {
562 requestType: pending.requestType,
563 peerName: pending.peerName,
564 peerIcon: pending.peerIcon,
565 request: pending.request,
566 walletAddress: unlockedWallet?.address || null,
567 };
568}
569
570// --- Handle confirmation from confirm.html ---
571
572async function handleBeaconConfirm(payload) {
573 const { requestId, approved } = payload;
574 const pending = pendingBeaconRequests.get(requestId);
575 if (!pending) {
576 return { error: 'No pending request found' };
577 }
578
579 pendingBeaconRequests.delete(requestId);
580
581 const beaconKp = await getOrCreateBeaconKeypair();
582 const peerPkBytes = hexToBytes(pending.peerPublicKey);
583
584 if (!approved) {
585 // Send error response: user rejected
586 if (pending.tabId) {
587 const err = buildBeaconError(
588 pending.request.id,
589 'NOT_GRANTED_ERROR',
590 pending.ourSenderId,
591 );
592 await sendBeaconResponseToTab(
593 pending.tabId,
594 err,
595 peerPkBytes,
596 beaconKp,
597 );
598 }
599 if (pending.resolve) pending.resolve(null);
600 return { success: true };
601 }
602
603 // --- Approved: build and send appropriate response ---
604 let response;
605
606 switch (pending.requestType) {
607 case 'permission_request':
608 response = await buildPermissionResponse(pending);
609 break;
610 case 'operation_request':
611 response = await buildOperationResponse(pending);
612 break;
613 case 'sign_payload_request':
614 response = await buildSignPayloadResponse(pending);
615 break;
616 default:
617 if (pending.resolve) pending.resolve(null);
618 return { error: 'Unknown request type' };
619 }
620
621 // If the response is an error (e.g., operation failed), send as error
622 if (response && response.type === 'error') {
623 if (pending.tabId) {
624 await sendBeaconResponseToTab(
625 pending.tabId,
626 response,
627 peerPkBytes,
628 beaconKp,
629 );
630 }
631 if (pending.resolve) pending.resolve(null);
632 return { success: true };
633 }
634
635 // Send encrypted response back to the dApp
636 if (pending.tabId && response) {
637 await sendBeaconResponseToTab(
638 pending.tabId,
639 response,
640 peerPkBytes,
641 beaconKp,
642 );
643 }
644
645 if (pending.resolve) pending.resolve(null);
646 return { success: true };
647}
648
649// --- Build permission response ---
650
651async function buildPermissionResponse(pending) {
652 if (!unlockedWallet) {
653 return buildBeaconError(
654 pending.request.id,
655 'NOT_GRANTED_ERROR',
656 pending.ourSenderId,
657 );
658 }
659
660 const storedState = await chrome.storage.local.get('network');
661 const network = storedState.network || 'ghostnet';
662
663 return {
664 type: 'permission_response',
665 id: pending.request.id,
666 publicKey: unlockedWallet.publicKey, // edpk... base58
667 network: { type: network },
668 scopes: ['sign', 'operation_request'],
669 appMetadata: {
670 senderId: pending.peerSenderId,
671 name: pending.peerName,
672 },
673 version: '2',
674 senderId: pending.ourSenderId,
675 };
676}
677
678// --- Build operation response ---
679
680async function buildOperationResponse(pending) {
681 if (!unlockedWallet) {
682 return buildBeaconError(
683 pending.request.id,
684 'NOT_GRANTED_ERROR',
685 pending.ourSenderId,
686 );
687 }
688
689 try {
690 const request = pending.request;
691 const storedState = await chrome.storage.local.get('network');
692 const network = storedState.network || 'ghostnet';
693
694 // Get the RPC node URL
695 const rpcUrl =
696 network === 'mainnet'
697 ? 'https://mainnet.api.tez.ie'
698 : 'https://ghostnet.teztnets.com';
699
700 // Forge the operations via RPC
701 const operationContents = request.operationDetails;
702 const sourceAddress = unlockedWallet.address;
703
704 // Get counter and branch
705 const [headRes, counterRes] = await Promise.all([
706 fetch(`${rpcUrl}/chains/main/blocks/head/header`),
707 fetch(
708 `${rpcUrl}/chains/main/blocks/head/context/contracts/${sourceAddress}/counter`,
709 ),
710 ]);
711
712 const head = await headRes.json();
713 const currentCounter = parseInt(await counterRes.json());
714 const branch = head.hash;
715
716 // Build the operations array with proper counter, source, etc.
717 let counter = currentCounter;
718 const contents = operationContents.map((op) => {
719 counter++;
720 const content = {
721 kind: op.kind,
722 source: sourceAddress,
723 counter: String(counter),
724 fee: op.fee || '0',
725 gas_limit: op.gas_limit || '10600',
726 storage_limit: op.storage_limit || '300',
727 };
728
729 if (op.kind === 'transaction') {
730 content.destination = op.destination;
731 content.amount = op.amount || '0';
732 if (op.parameters) {
733 content.parameters = op.parameters;
734 }
735 } else if (op.kind === 'origination') {
736 content.balance = op.balance || '0';
737 content.script = op.script;
738 } else if (op.kind === 'delegation') {
739 if (op.delegate) content.delegate = op.delegate;
740 }
741
742 return content;
743 });
744
745 // Forge via RPC
746 const forgeRes = await fetch(
747 `${rpcUrl}/chains/main/blocks/head/helpers/forge/operations`,
748 {
749 method: 'POST',
750 headers: { 'Content-Type': 'application/json' },
751 body: JSON.stringify({ branch, contents }),
752 },
753 );
754
755 const forgedBytes = (await forgeRes.json()).replace(/^"|"$/g, '');
756
757 // Sign the forged operation
758 const signature = await cryptoSignOperation(
759 forgedBytes,
760 unlockedWallet.secretKeyBytes,
761 );
762
763 // Inject the signed operation
764 const signedOp = signature.signedBytes;
765 const injectRes = await fetch(
766 `${rpcUrl}/injection/operation`,
767 {
768 method: 'POST',
769 headers: { 'Content-Type': 'application/json' },
770 body: JSON.stringify(signedOp),
771 },
772 );
773
774 const opHash = (await injectRes.json()).replace(/^"|"$/g, '');
775
776 resetLockTimeout();
777
778 return {
779 type: 'operation_response',
780 id: pending.request.id,
781 transactionHash: opHash,
782 version: '2',
783 senderId: pending.ourSenderId,
784 };
785 } catch (error) {
786 console.error('Beacon operation error:', error);
787 return {
788 type: 'error',
789 id: pending.request.id,
790 version: '2',
791 senderId: pending.ourSenderId,
792 errorType: 'TRANSACTION_INVALID_ERROR',
793 errorData: [{ kind: 'temporary', id: error.message }],
794 };
795 }
796}
797
798// --- Build sign payload response ---
799
800async function buildSignPayloadResponse(pending) {
801 if (!unlockedWallet) {
802 return buildBeaconError(
803 pending.request.id,
804 'NOT_GRANTED_ERROR',
805 pending.ourSenderId,
806 );
807 }
808
809 try {
810 const request = pending.request;
811 const payloadToSign = request.payload;
812
813 // Sign the payload (it comes as a hex string from the dApp)
814 const signature = await cryptoSign(
815 payloadToSign,
816 unlockedWallet.secretKeyBytes,
817 );
818
819 resetLockTimeout();
820
821 return {
822 type: 'sign_payload_response',
823 id: pending.request.id,
824 signingType: request.signingType || 'raw',
825 signature: signature.edsig,
826 version: '2',
827 senderId: pending.ourSenderId,
828 };
829 } catch (error) {
830 console.error('Beacon sign payload error:', error);
831 return {
832 type: 'error',
833 id: pending.request.id,
834 version: '2',
835 senderId: pending.ourSenderId,
836 errorType: 'SIGNATURE_TYPE_NOT_SUPPORTED',
837 };
838 }
839}
840
841// ============================================================
842// Existing Wallet Operations (unchanged)
843// ============================================================
844
845// Wallet State
846async function getWalletState() {
847 const stored = await chrome.storage.local.get([
848 'encryptedSeed',
849 'address',
850 'publicKey',
851 'network',
852 ]);
853 return {
854 exists: !!stored.encryptedSeed,
855 unlocked: !!unlockedWallet,
856 address: stored.address || null,
857 publicKey: stored.publicKey || null,
858 network: stored.network || 'ghostnet',
859 };
860}
861
862// Create new wallet
863async function createWallet(password) {
864 if (!password || password.length < 8) {
865 return { error: 'Password must be at least 8 characters' };
866 }
867
868 await initCrypto();
869
870 const mnemonic = generateMnemonic();
871 const keypair = await deriveKeypair(mnemonic);
872 const encryptedSeed = await encrypt(mnemonic, password);
873
874 await chrome.storage.local.set({
875 encryptedSeed,
876 address: keypair.address,
877 publicKey: keypair.publicKey,
878 network: 'ghostnet',
879 });
880
881 unlockedWallet = {
882 address: keypair.address,
883 publicKey: keypair.publicKey,
884 secretKeyBytes: keypair.secretKeyBytes,
885 };
886 resetLockTimeout();
887
888 return {
889 success: true,
890 mnemonic,
891 address: keypair.address,
892 };
893}
894
895// Import existing wallet
896async function importWallet(mnemonic, password) {
897 if (!password || password.length < 8) {
898 return { error: 'Password must be at least 8 characters' };
899 }
900
901 const cleanMnemonic = mnemonic.trim().toLowerCase().replace(/\s+/g, ' ');
902 if (!validateMnemonic(cleanMnemonic)) {
903 return { error: 'Invalid seed phrase' };
904 }
905
906 await initCrypto();
907
908 const keypair = await deriveKeypair(cleanMnemonic);
909 const encryptedSeed = await encrypt(cleanMnemonic, password);
910
911 await chrome.storage.local.set({
912 encryptedSeed,
913 address: keypair.address,
914 publicKey: keypair.publicKey,
915 network: 'ghostnet',
916 });
917
918 unlockedWallet = {
919 address: keypair.address,
920 publicKey: keypair.publicKey,
921 secretKeyBytes: keypair.secretKeyBytes,
922 };
923 resetLockTimeout();
924
925 return {
926 success: true,
927 address: keypair.address,
928 };
929}
930
931// Import wallet from private key
932async function importPrivateKey(privateKey, password) {
933 if (!password || password.length < 8) {
934 return { error: 'Password must be at least 8 characters' };
935 }
936
937 if (!privateKey || !privateKey.startsWith('edsk')) {
938 return { error: 'Invalid private key format' };
939 }
940
941 try {
942 await initCrypto();
943
944 const keypair = await importFromPrivateKey(privateKey.trim());
945 const encryptedSeed = await encrypt(privateKey.trim(), password);
946
947 await chrome.storage.local.set({
948 encryptedSeed,
949 importType: 'privateKey',
950 address: keypair.address,
951 publicKey: keypair.publicKey,
952 network: 'ghostnet',
953 });
954
955 unlockedWallet = {
956 address: keypair.address,
957 publicKey: keypair.publicKey,
958 secretKeyBytes: keypair.secretKeyBytes,
959 };
960 resetLockTimeout();
961
962 return {
963 success: true,
964 address: keypair.address,
965 };
966 } catch (error) {
967 console.error('Import private key error:', error);
968 return { error: error.message || 'Failed to import private key' };
969 }
970}
971
972// Unlock wallet
973async function unlockWallet(password) {
974 const { encryptedSeed, importType, address, publicKey } =
975 await chrome.storage.local.get([
976 'encryptedSeed',
977 'importType',
978 'address',
979 'publicKey',
980 ]);
981
982 if (!encryptedSeed) {
983 return { error: 'No wallet found' };
984 }
985
986 try {
987 await initCrypto();
988
989 const decrypted = await decrypt(encryptedSeed, password);
990
991 let keypair;
992 if (importType === 'privateKey') {
993 keypair = await importFromPrivateKey(decrypted);
994 } else {
995 keypair = await deriveKeypair(decrypted);
996 }
997
998 if (keypair.address !== address) {
999 return { error: 'Key derivation mismatch' };
1000 }
1001
1002 unlockedWallet = {
1003 address: keypair.address,
1004 publicKey: keypair.publicKey,
1005 secretKeyBytes: keypair.secretKeyBytes,
1006 };
1007
1008 clearSensitiveData(decrypted);
1009 resetLockTimeout();
1010
1011 return { success: true, address };
1012 } catch (error) {
1013 return { error: 'Invalid password' };
1014 }
1015}
1016
1017// Lock wallet
1018function lockWallet() {
1019 if (unlockedWallet?.secretKeyBytes) {
1020 clearSensitiveData(unlockedWallet.secretKeyBytes);
1021 }
1022 unlockedWallet = null;
1023
1024 if (lockTimeout) {
1025 clearTimeout(lockTimeout);
1026 lockTimeout = null;
1027 }
1028
1029 // Notify popup and content scripts
1030 chrome.runtime.sendMessage({ type: 'KEEPS_LOCKED' }).catch(() => {});
1031
1032 return { success: true };
1033}
1034
1035// Reset auto-lock timeout
1036function resetLockTimeout() {
1037 if (lockTimeout) clearTimeout(lockTimeout);
1038 lockTimeout = setTimeout(() => {
1039 lockWallet();
1040 }, LOCK_TIMEOUT_MS);
1041}
1042
1043// Get address
1044function getAddress() {
1045 return { address: unlockedWallet?.address || null };
1046}
1047
1048// Set network
1049async function setNetwork(network) {
1050 if (network !== 'mainnet' && network !== 'ghostnet') {
1051 return { error: 'Invalid network' };
1052 }
1053 await chrome.storage.local.set({ network });
1054 return { success: true, network };
1055}
1056
1057// Handle signing request (with confirmation)
1058async function handleSignRequest(operation, sender) {
1059 if (!unlockedWallet) {
1060 return { error: 'Wallet is locked' };
1061 }
1062
1063 console.log('Signing operation:', operation);
1064
1065 try {
1066 const signature = await cryptoSignOperation(
1067 operation.forgedBytes,
1068 unlockedWallet.secretKeyBytes,
1069 );
1070 resetLockTimeout();
1071 return {
1072 success: true,
1073 signature: signature.edsig,
1074 signedBytes: signature.signedBytes,
1075 };
1076 } catch (error) {
1077 return { error: 'Signing failed: ' + error.message };
1078 }
1079}
1080
1081// Get balance from TzKT
1082async function getBalance(network) {
1083 const state = await getWalletState();
1084 const address = state.address;
1085 if (!address) return { balance: null };
1086
1087 const net = network || state.network || 'ghostnet';
1088 const apiBase =
1089 net === 'mainnet'
1090 ? 'https://api.tzkt.io'
1091 : 'https://api.ghostnet.tzkt.io';
1092
1093 try {
1094 const res = await fetch(`${apiBase}/v1/accounts/${address}/balance`);
1095 const mutez = await res.json();
1096 return { balance: mutez / 1000000 };
1097 } catch (error) {
1098 return { error: error.message };
1099 }
1100}
1101
1102// Get keeps (NFTs) from TzKT
1103async function getKeeps(network) {
1104 const state = await getWalletState();
1105 const address = state.address;
1106 if (!address) return { keeps: [] };
1107
1108 const net = network || state.network || 'ghostnet';
1109 const apiBase =
1110 net === 'mainnet'
1111 ? 'https://api.tzkt.io'
1112 : 'https://api.ghostnet.tzkt.io';
1113
1114 try {
1115 const res = await fetch(
1116 `${apiBase}/v1/tokens/balances?account=${address}&balance.gt=0&token.standard=fa2&limit=100`,
1117 );
1118 const tokens = await res.json();
1119
1120 const keeps = tokens.map((t) => ({
1121 id: t.token.tokenId,
1122 contract: t.token.contract.address,
1123 balance: parseInt(t.balance),
1124 name: t.token.metadata?.name,
1125 description: t.token.metadata?.description,
1126 thumbnailUri: t.token.metadata?.thumbnailUri,
1127 displayUri: t.token.metadata?.displayUri,
1128 artifactUri: t.token.metadata?.artifactUri,
1129 }));
1130
1131 return { keeps };
1132 } catch (error) {
1133 return { error: error.message, keeps: [] };
1134 }
1135}
1136
1137console.log('Keeps Wallet background service worker loaded');