diff --git a/CHROME_STORE_LISTING.md b/CHROME_STORE_LISTING.md new file mode 100644 index 0000000..f93dce3 --- /dev/null +++ b/CHROME_STORE_LISTING.md @@ -0,0 +1,42 @@ +# Chrome Web Store Listing – Lumen Wallet + +## Extension Name +Lumen Wallet + +## Short Description (≤ 132 chars) +Non‑custodial Lumen wallet with PQC support, swaps, and secure dApp connections. + +## Detailed Description +Lumen Wallet is a non‑custodial browser extension for the Lumen blockchain. +It lets you create and manage Lumen accounts, connect to dApps, and sign transactions securely. + +**Key features** +- Non‑custodial: keys are encrypted and stored locally +- PQC‑ready signing (Dilithium3) +- dApp connection & transaction approvals +- Swap v1 support (Lumen ecosystem) +- Multi‑account support + +**Security & Privacy** +- We never store or transmit your seed phrase or private keys. +- All signing happens locally in the extension. + +**Network** +- Uses public Lumen RPC/REST endpoints for chain data. + +## Category +Productivity / Developer Tools / Finance (choose best fit) + +## Website / Support +- Website: `https://YOUR_DOMAIN_HERE` +- Support: `support@YOUR_DOMAIN_HERE` + +## Privacy Policy URL +`https://YOUR_DOMAIN_HERE/privacy` + +## Keywords (optional) +Lumen, Cosmos, wallet, blockchain, PQC, Dilithium, crypto, non‑custodial + +## Notes for Reviewers (optional) +This extension injects a provider to enable dApps to request wallet actions. +All sensitive data remains on device. diff --git a/PERMISSIONS_JUSTIFICATION.md b/PERMISSIONS_JUSTIFICATION.md new file mode 100644 index 0000000..0db73a6 --- /dev/null +++ b/PERMISSIONS_JUSTIFICATION.md @@ -0,0 +1,30 @@ +# Permissions Justification – Lumen Wallet + +This document explains why each permission is required. + +## Chrome Extension Permissions +- **storage** + Store encrypted wallet vault, user settings, connected dApps, and pending approvals locally. + +- **sidePanel** + Show approval requests consistently without opening multiple popups. + +- **contextMenus** + Provide quick access actions from the extension icon menu. + +## Host Permissions (Optional) +The extension interacts with Lumen RPC/REST endpoints to query balances, chain state, and submit transactions. + +### Why broad host permissions exist +The extension is intended to connect to many dApps across different domains. +Some flows require access to specific RPC/REST endpoints and dApp domains. + +> NOTE: For production, we recommend restricting or dynamically requesting host permissions only for approved domains. + +## Content Script Injection +The provider is injected so dApps can request wallet actions (connect, sign, etc.). +This is standard for blockchain wallets and is required for compatibility with dApps. + +--- + +If any reviewer needs more details, contact: `support@YOUR_DOMAIN_HERE`. diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md new file mode 100644 index 0000000..252303d --- /dev/null +++ b/PRIVACY_POLICY.md @@ -0,0 +1,18 @@ +# Lumen Wallet Privacy Policy + +**Last updated:** 2026-02-15 + +Lumen Wallet is a non‑custodial browser extension for the Lumen blockchain. This policy explains what data is handled and how. + +## Summary +- We do **not** collect, sell, or share personal data. +- Wallet keys are generated and stored **locally** on your device, encrypted with your password. +- No seed phrase or private key is ever sent to our servers. + +## Data We Store Locally +The extension stores the following data **only on your device**: +- Encrypted wallet vault (keys + PQC data) +- Connected dApp permissions (approved domains) +- Recent settings (theme, active account, etc.) + +## Network Requests diff --git a/SECURITY_REVIEW.md b/SECURITY_REVIEW.md new file mode 100644 index 0000000..fede7f8 --- /dev/null +++ b/SECURITY_REVIEW.md @@ -0,0 +1,18 @@ +# Security Review (Internal) + +## Scope +- Inpage ↔ Content Script ↔ Background message flow +- Permission gating & approvals +- Wallet vault storage & session handling +- Transaction signing (Amino / Direct) + +## Findings Checklist +- [ ] No seed/private key leakage in logs +- [ ] Origin validation for provider requests +- [ ] Approval requires explicit user gesture +- [ ] Pending queue recovery on service worker restart +- [ ] ChainId strict checking (Lumen only) +- [ ] RPC/REST endpoints health check & fallback + +## Reviewer Notes +Add notes and date here after internal review. diff --git a/STORE_SUBMISSION_CHECKLIST.md b/STORE_SUBMISSION_CHECKLIST.md new file mode 100644 index 0000000..bf6afb5 --- /dev/null +++ b/STORE_SUBMISSION_CHECKLIST.md @@ -0,0 +1,19 @@ +# Chrome Store Submission Checklist + +## Required (before submit) +- [ ] **Privacy Policy URL** hosted publicly (HTTPS) +- [ ] **Support contact** (email + website) +- [ ] **Data Disclosure** completed in Chrome Web Store dashboard +- [ ] **Permission justifications** ready for reviewer +- [ ] **Screenshots + icon assets** uploaded + +## Data Disclosure (suggested answers) +- **Data collected:** None +- **Data shared:** None +- **Data sold:** No +- **Data processed off device:** No (all keys stay local) + +## Notes for Reviewer +- Non‑custodial wallet, keys stored locally and encrypted +- dApp approval requires explicit user consent +- RPC/REST endpoints are public network calls for chain data diff --git a/background.js b/background.js index 9d6064c..c2a1c68 100644 --- a/background.js +++ b/background.js @@ -26,3 +26,221 @@ if (typeof chrome !== 'undefined' && chrome.contextMenus && chrome.contextMenus. } }); } + +/** + * LUMEN WALLET PROVIDER - BACKGROUND SERVICE WORKER + * + * This is the central hub for all wallet operations. + * It receives requests from Content Scripts and processes them. + * + * RESPONSIBILITIES: + * - Handle provider requests (enable, sign, send transactions, etc.) + * - Manage wallet state and storage + * - Trigger popup/notifications for user confirmations + * - Emit events to connected dApps (accountsChanged, chainChanged) + */ + +// Message handler for provider requests +if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) { + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // Only handle Lumen provider requests + if (message.type !== 'lumen-provider-request') { + return false; // Not our message + } + + console.log('[Lumen Background] Received request:', message); + + // Handle async processing + handleProviderRequest(message, sender) + .then(response => sendResponse(response)) + .catch(error => { + console.error('[Lumen Background] Error:', error); + sendResponse({ error: error.message || 'Request failed' }); + }); + + // Return true to indicate async response + return true; + }); +} + +/** + * Process provider requests + * @param {Object} message - Request from content script + * @param {Object} sender - Chrome sender info (tab, frameId, etc.) + * @returns {Promise} Response object with data or error + */ +async function handleProviderRequest(message, sender) { + const { method, params, origin } = message; + + try { + switch (method) { + // Wallet connection/enable request + case 'eth_requestAccounts': + case 'enable': + return await handleEnable(origin, sender.tab); + + // Get current accounts + case 'eth_accounts': + return await handleGetAccounts(origin); + + // Chain ID request + case 'eth_chainId': + case 'net_version': + return await handleGetChainId(); + + // Sign transaction + case 'eth_sendTransaction': + return await handleSendTransaction(params, origin, sender.tab); + + // Sign message + case 'personal_sign': + case 'eth_sign': + return await handleSignMessage(params, origin, sender.tab); + + // Sign typed data (EIP-712) + case 'eth_signTypedData': + case 'eth_signTypedData_v3': + case 'eth_signTypedData_v4': + return await handleSignTypedData(params, origin, sender.tab); + + // Add network/chain + case 'wallet_addEthereumChain': + return await handleAddChain(params, origin, sender.tab); + + // Switch network/chain + case 'wallet_switchEthereumChain': + return await handleSwitchChain(params, origin, sender.tab); + + // Cosmos-specific methods (for Lumen chain) + case 'cosmos_getKey': + case 'getKey': + return await handleCosmosGetKey(params?.chainId); + + case 'cosmos_signAmino': + case 'signAmino': + return await handleCosmosSignAmino(params, origin, sender.tab); + + case 'cosmos_signDirect': + case 'signDirect': + return await handleCosmosSignDirect(params, origin, sender.tab); + + default: + throw new Error(`Unsupported method: ${method}`); + } + } catch (error) { + console.error(`[Lumen Background] Error handling ${method}:`, error); + return { error: error.message }; + } +} + +/** + * PLACEHOLDER HANDLERS + * These should be implemented with actual wallet logic + */ + +async function handleEnable(origin, tab) { + // TODO: Check if wallet is locked - if so, open unlock screen + // TODO: Check if origin is already connected + // TODO: If not, open approval popup for user to approve/reject connection + // TODO: Return array of account addresses + + console.log('[Lumen] Enable request from:', origin); + + // Placeholder response - replace with actual wallet logic + return { + data: ['0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1'] // Example address + }; +} + +async function handleGetAccounts(origin) { + // TODO: Check if origin is connected + // TODO: Return accounts only if connected, empty array otherwise + + console.log('[Lumen] Get accounts request from:', origin); + return { + data: ['0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1'] + }; +} + +async function handleGetChainId() { + // TODO: Return current chain ID from wallet state + console.log('[Lumen] Get chain ID request'); + return { + data: '0x1' // Mainnet + }; +} + +async function handleSendTransaction(params, origin, tab) { + // TODO: Validate transaction + // TODO: Open popup for user confirmation + // TODO: Sign and broadcast transaction + // TODO: Return transaction hash + + console.log('[Lumen] Send transaction request:', params); + throw new Error('Transaction signing not yet implemented'); +} + +async function handleSignMessage(params, origin, tab) { + // TODO: Open popup for user to review and sign message + // TODO: Sign with private key + // TODO: Return signature + + console.log('[Lumen] Sign message request:', params); + throw new Error('Message signing not yet implemented'); +} + +async function handleSignTypedData(params, origin, tab) { + // TODO: Parse and validate EIP-712 typed data + // TODO: Open popup for user to review + // TODO: Sign and return signature + + console.log('[Lumen] Sign typed data request:', params); + throw new Error('Typed data signing not yet implemented'); +} + +async function handleAddChain(params, origin, tab) { + // TODO: Validate chain parameters + // TODO: Prompt user to add chain + // TODO: Save to storage if approved + + console.log('[Lumen] Add chain request:', params); + return { data: null }; +} + +async function handleSwitchChain(params, origin, tab) { + // TODO: Check if chain exists + // TODO: Switch active chain + // TODO: Emit chainChanged event + + console.log('[Lumen] Switch chain request:', params); + throw new Error('Chain switching not yet implemented'); +} + +// Cosmos-specific handlers for Lumen chain +async function handleCosmosGetKey(chainId) { + // TODO: Return public key and address for specified chain + console.log('[Lumen] Cosmos getKey request:', chainId); + throw new Error('Cosmos key retrieval not yet implemented'); +} + +async function handleCosmosSignAmino(params, origin, tab) { + // TODO: Sign Amino transaction (legacy Cosmos tx format) + console.log('[Lumen] Cosmos signAmino request:', params); + throw new Error('Cosmos Amino signing not yet implemented'); +} + +async function handleCosmosSignDirect(params, origin, tab) { + // TODO: Sign Direct transaction (Protobuf Cosmos tx format) + console.log('[Lumen] Cosmos signDirect request:', params); + throw new Error('Cosmos Direct signing not yet implemented'); +} + +/** + * Emit events to connected tabs + * Call this when accounts or chain changes + */ +async function emitProviderEvent(eventName, data) { + // TODO: Get all connected tabs + // TODO: Send message to content scripts in those tabs + console.log('[Lumen] Would emit event:', eventName, data); +} diff --git a/chrome-extension.1.0.0.zip b/chrome-extension.1.0.0.zip deleted file mode 100644 index 0cff90d..0000000 Binary files a/chrome-extension.1.0.0.zip and /dev/null differ diff --git a/manifest.json b/manifest.json index 9859a03..f58f6a4 100644 --- a/manifest.json +++ b/manifest.json @@ -12,12 +12,18 @@ "sidePanel", "contextMenus" ], - "host_permissions": [ - "http://142.132.201.187:1317/*", - "http://142.132.201.187:26657/*", - "http://142.132.201.187:9190/*", + "optional_host_permissions": [ + "https://*/*", "http://*/*", - "https://*/*" + "https://rpc.lumen.chaintools.tech/*", + "https://lumen.blocksync.me/*", + "https://lumen-mainnet-rpc.mekonglabs.com/*", + "https://rpc-lumen.onenov.xyz/*", + "https://lumen-api.node9x.com/*", + "https://api.lumen.chaintools.tech/*", + "https://lumen-mainnet-api.mekonglabs.com/*", + "https://lumen-api.linknode.org/*", + "https://api-lumen.winnode.xyz/*" ], "side_panel": { "default_path": "index.html" @@ -40,11 +46,20 @@ "48": "icons/logo.png", "128": "icons/logo.png" }, + "content_scripts": [ + { + "matches": [""], + "js": ["contentScript.js"], + "run_at": "document_start", + "all_frames": true + } + ], "web_accessible_resources": [ { "resources": [ "*.wasm", - "dilithium3.wasm" + "dilithium3.wasm", + "inject.js" ], "matches": [ "" diff --git a/package.json b/package.json index 9607a75..7e83623 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && node scripts/copy-manifest.js", "build:zip": "npm run build && node scripts/create-zip.js", "lint": "eslint .", "preview": "vite preview" diff --git a/scripts/copy-manifest.js b/scripts/copy-manifest.js new file mode 100644 index 0000000..3c61d8e --- /dev/null +++ b/scripts/copy-manifest.js @@ -0,0 +1,20 @@ +import { copyFileSync } from 'fs'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const manifestPath = resolve(__dirname, '../manifest.json'); +const distManifestPath = resolve(__dirname, '../dist/manifest.json'); + +console.log('\x1b[36m%s\x1b[0m', '📋 Copying manifest.json to dist...'); + +try { + copyFileSync(manifestPath, distManifestPath); + console.log('\x1b[32m%s\x1b[0m', '✓ manifest.json copied successfully!'); +} catch (error) { + console.error('\x1b[31m%s\x1b[0m', '✗ Error copying manifest.json:', error.message); + process.exit(1); +} diff --git a/src/App.tsx b/src/App.tsx index 5029cb3..4055d79 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,8 +9,10 @@ import { BackupModal } from './components/dashboard/BackupModal'; import { Staking } from './components/staking/Staking'; import { Governance } from './components/governance/Governance'; import { VaultManager } from './modules/vault/vault'; +import { REQUIRED_HOST_PERMISSIONS } from './permissions'; import { openExpandedView } from './utils/navigation'; import { Send } from './components/send/Send'; +import { ApprovalModal } from './components/ApprovalModal'; import type { LumenWallet } from './modules/sdk/key-manager'; function App() { @@ -27,8 +29,14 @@ function App() { const [isLocked, setIsLocked] = useState(false); const [hasVault, setHasVault] = useState(false); const [loading, setLoading] = useState(true); + const [showApprovalModal, setShowApprovalModal] = useState(false); const [unlockError, setUnlockError] = useState(null); const isLockedRef = useRef(isLocked); + const [needsNetworkPermission, setNeedsNetworkPermission] = useState(false); + const [permissionError, setPermissionError] = useState(null); + const permissionCheckedRef = useRef(false); + const [pendingCount, setPendingCount] = useState(0); + const STORAGE_PENDING_QUEUE = 'pendingApprovalQueue'; useEffect(() => { isLockedRef.current = isLocked; @@ -53,16 +61,66 @@ function App() { setTheme(prev => prev === 'dark' ? 'light' : 'dark'); }; + const checkNetworkPermissions = async () => { + if (!chrome?.permissions) return; + try { + const granted = await chrome.permissions.contains({ origins: REQUIRED_HOST_PERMISSIONS }); + setNeedsNetworkPermission(!granted); + } catch { + setNeedsNetworkPermission(true); + } + }; + + const requestNetworkPermissions = async () => { + if (!chrome?.permissions) return; + setPermissionError(null); + try { + const granted = await chrome.permissions.request({ origins: REQUIRED_HOST_PERMISSIONS }); + if (!granted) { + setPermissionError('Network access denied. Some features may not work.'); + } + setNeedsNetworkPermission(!granted); + } catch (e: any) { + setPermissionError(e?.message || 'Failed to request network permissions.'); + setNeedsNetworkPermission(true); + } + }; + /* Persist active wallet */ useEffect(() => { if (wallets.length > 0 && wallets[activeWalletIndex]) { - localStorage.setItem('lastActiveWalletAddress', wallets[activeWalletIndex].address); + const activeAddress = wallets[activeWalletIndex].address; + localStorage.setItem('lastActiveWalletAddress', activeAddress); + chrome.runtime.sendMessage({ type: 'sync-active-wallet', address: activeAddress }).catch(() => { + }); } }, [activeWalletIndex, wallets]); + const loadPendingQueue = async () => { + const result = await chrome.storage.local.get(STORAGE_PENDING_QUEUE) as { pendingApprovalQueue?: any[] }; + const queue = Array.isArray(result.pendingApprovalQueue) ? result.pendingApprovalQueue : []; + setPendingCount(queue.length); + return queue; + }; + /* Initial Load & Session Check */ useEffect(() => { const checkSession = async () => { + // Check for pending approval - but only show modal AFTER wallet is unlocked + try { + const queue = await loadPendingQueue(); + if (queue.length > 0) { + const expired = await VaultManager.isSessionExpired(); + if (!expired) { + setShowApprovalModal(true); + } else { + } + } else { + setShowApprovalModal(false); + } + } catch (e) { + } + const exists = await VaultManager.hasWallet(); setHasVault(exists); if (exists) { @@ -80,9 +138,14 @@ function App() { setActiveWalletIndex(foundIdx); } + // No pending approval, proceed to dashboard if (location.pathname === '/' || location.pathname === '/onboarding') { navigate('/dashboard'); } + if (!permissionCheckedRef.current) { + permissionCheckedRef.current = true; + await checkNetworkPermissions(); + } } else { setIsLocked(true); if (location.pathname === '/onboarding') { @@ -112,6 +175,25 @@ function App() { }; checkSession(); + const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }, areaName: string) => { + if (areaName !== 'local') return; + if (!changes.pendingApprovalQueue) return; + const nextValue = changes.pendingApprovalQueue.newValue; + const queue = Array.isArray(nextValue) ? nextValue : []; + setPendingCount(queue.length); + if (queue.length > 0 && !isLockedRef.current) { + setShowApprovalModal(true); + if (location.pathname !== '/dashboard') { + navigate('/dashboard'); + } + } + if (queue.length === 0) { + setShowApprovalModal(false); + } + }; + + chrome.storage.onChanged.addListener(handleStorageChange); + const interval = setInterval(async () => { const exists = await VaultManager.hasWallet(); setHasVault(exists); @@ -122,11 +204,18 @@ function App() { } }, 5000); - return () => clearInterval(interval); + return () => { + clearInterval(interval); + chrome.storage.onChanged.removeListener(handleStorageChange); + }; }, []); const handleUnlock = async (password: string) => { try { + // Sync session to background + chrome.runtime.sendMessage({ type: 'sync-session', password }).catch(() => { + }); + const unlockedWallets = await VaultManager.unlock(password); setWallets(unlockedWallets); setHasVault(true); @@ -140,7 +229,20 @@ function App() { setIsLocked(false); setUnlockError(null); - navigate('/dashboard'); + + if (!permissionCheckedRef.current) { + permissionCheckedRef.current = true; + await checkNetworkPermissions(); + } + + // Check for pending approval request AFTER unlock + const queue = await loadPendingQueue(); + if (queue.length > 0) { + setShowApprovalModal(true); + navigate('/dashboard'); // Go to dashboard with modal overlay + } else { + navigate('/dashboard'); + } } catch (e: any) { setUnlockError(e?.message || "Incorrect password."); } @@ -303,6 +405,35 @@ function App() { )}
+ {!isLocked && activeWallet && needsNetworkPermission && ( +
+
+
+
Network access required
+
Allow access to Lumen RPC/REST endpoints for balance and transactions.
+ {permissionError && ( +
{permissionError}
+ )} +
+ +
+
+ )} + {!isLocked && pendingCount > 0 && ( +
+
+
+
Pending requests
+
{pendingCount} waiting for your approval.
+
+
+
+ )} )} + + {/* Approval Modal Overlay */} + {(showApprovalModal || (!isLocked && pendingCount > 0)) && ( + { + setShowApprovalModal(false); + }} + /> + )} ); } diff --git a/src/background.ts b/src/background.ts index 9c5618d..40ec01b 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,3 +1,123 @@ +// Import VaultManager for wallet operations +import { VaultManager } from './modules/vault/vault'; +import { AminoTypes, createDefaultAminoConverters, defaultRegistryTypes } from '@cosmjs/stargate'; +import { DirectSecp256k1HdWallet, Registry, encodePubkey, makeAuthInfoBytes } from '@cosmjs/proto-signing'; +import { Secp256k1HdWallet, getAminoPubkey } from '@cosmjs/amino'; +import { fromBase64, fromBech32 } from '@cosmjs/encoding'; +import { SignMode } from 'cosmjs-types/cosmos/tx/signing/v1beta1/signing'; +import { TxRaw } from 'cosmjs-types/cosmos/tx/v1beta1/tx'; +import { Buffer } from 'buffer'; +import * as LumenSDK from '@lumen-chain/sdk'; +import { NetworkManager } from './modules/sdk/network'; + +// Initialize Network Manager +NetworkManager.getInstance(); + +const CHAIN_ID = 'lumen'; +const PQC_PUBLIC_KEY_BYTES = 1952; +const PQC_PRIVATE_KEY_BYTES = 4000; +const defaultRegistry = new Registry(defaultRegistryTypes); +const defaultAminoTypes = new AminoTypes(createDefaultAminoConverters()); +const REQUIRED_HOST_PERMISSIONS = [ + 'https://rpc.lumen.chaintools.tech/*', + 'https://lumen.blocksync.me/*', + 'https://lumen-mainnet-rpc.mekonglabs.com/*', + 'https://rpc-lumen.onenov.xyz/*', + 'https://lumen-api.node9x.com/*', + 'https://api.lumen.chaintools.tech/*', + 'https://lumen-mainnet-api.mekonglabs.com/*', + 'https://lumen-api.linknode.org/*', + 'https://api-lumen.winnode.xyz/*' +]; + +const ensureUint8Array = (input: string | Uint8Array | undefined): Uint8Array => { + if (!input) return new Uint8Array(0); + if (typeof input === 'string') { + const trimmed = input.trim(); + if (trimmed.length === 0) return new Uint8Array(0); + + if (/^[0-9a-fA-F]+$/.test(trimmed)) { + try { + const buf = Buffer.from(trimmed, 'hex'); + if (buf.length > 0) return new Uint8Array(buf); + } catch (e) { + /* ignore hex decode errors */ + } + } + + try { + const buf = Buffer.from(trimmed, 'base64'); + if (buf.length > 0) return new Uint8Array(buf); + } catch (e) { + /* ignore base64 decode errors */ + } + + try { + const binString = atob(trimmed); + return new Uint8Array(binString.split('').map(c => c.charCodeAt(0))); + } catch (e) { + return new Uint8Array(0); + } + } + return new Uint8Array(input as any); +}; + +const toNumber = (value: unknown, label: string): number => { + if (value === undefined || value === null) { + throw new Error(`Missing ${label}`); + } + if (typeof value === 'bigint') { + return Number(value); + } + if (typeof value === 'object' && value !== null && 'toString' in value) { + const maybeString = (value as { toString: () => string }).toString(); + const num = Number(maybeString); + if (!Number.isFinite(num)) { + throw new Error(`Invalid ${label}: ${maybeString}`); + } + return num; + } + const num = Number(value); + if (!Number.isFinite(num)) { + throw new Error(`Invalid ${label}: ${String(value)}`); + } + return num; +}; + +const getPqcKeys = (walletData: any): { pqcPrivKey: Uint8Array; pqcPubKey: Uint8Array } => { + const pqcData = ((walletData.pqcKey as any)?.publicKey || (walletData.pqcKey as any)?.public_key) + ? walletData.pqcKey + : ((walletData.pqc as any)?.publicKey || (walletData.pqc as any)?.public_key) + ? walletData.pqc + : (walletData.pqcKey || walletData.pqc); + + if (!pqcData) { + throw new Error('Wallet is missing PQC key data. Please re-import your wallet.'); + } + + const rawPriv = pqcData.privateKey || pqcData.private_key || pqcData.encryptedPrivateKey; + const rawPub = pqcData.publicKey || pqcData.public_key; + + if (!rawPriv || !rawPub) { + throw new Error('PQC keys missing sub-properties. Please re-import your wallet.'); + } + + const pqcPrivKey = ensureUint8Array(rawPriv); + const pqcPubKey = ensureUint8Array(rawPub); + + if (pqcPubKey.length !== PQC_PUBLIC_KEY_BYTES) { + throw new Error(`Invalid PQC Public Key. Expected ${PQC_PUBLIC_KEY_BYTES} bytes, got ${pqcPubKey.length}.`); + } + + if (pqcPrivKey.length !== PQC_PRIVATE_KEY_BYTES) { + throw new Error(`Invalid PQC Private Key. Expected ${PQC_PRIVATE_KEY_BYTES} bytes, got ${pqcPrivKey.length}.`); + } + + return { pqcPrivKey, pqcPubKey }; +}; + + + // Set panel behavior if (typeof chrome !== 'undefined' && chrome.sidePanel && chrome.sidePanel.setPanelBehavior) { chrome.sidePanel @@ -29,3 +149,1046 @@ if (typeof chrome !== 'undefined' && chrome.contextMenus && chrome.contextMenus. } }); } + +// Handle background alarms for periodic tasks +if (typeof chrome !== 'undefined' && chrome.alarms) { + chrome.alarms.create('refresh-rpc', { periodInMinutes: 5 }); + chrome.alarms.create('keepalive', { periodInMinutes: 1 }); + chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === 'refresh-rpc') { + NetworkManager.getInstance().refreshBestRpc(); + } + if (alarm.name === 'keepalive') { + chrome.storage.local.get(['connectedOrigins']).catch(() => { }); + } + }); +} + +/** + * LUMEN WALLET PROVIDER - BACKGROUND SERVICE WORKER + * + * This is the central hub for all wallet operations. + * It receives requests from Content Scripts and processes them. + * + * RESPONSIBILITIES: + * - Handle provider requests (enable, sign, send transactions, etc.) + * - Manage wallet state and storage + * - Trigger popup/notifications for user confirmations + * - Emit events to connected dApps (accountsChanged, chainChanged) + */ + +// Message handler for provider requests from content scripts AND UI responses +if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) { + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === 'lumen-ping') { + sendResponse({ ok: true }); + return false; + } + // Handle provider requests from content scripts + if (message.type === 'lumen-provider-request') { + + handleProviderRequest(message, sender) + .then(response => sendResponse(response)) + .catch(error => { + console.error('[Lumen Background] Error:', error); + sendResponse({ error: error.message || 'Request failed' }); + }); + + return true; + } + + /* Handle user approval/rejection from side panel UI */ + if (message.type === 'user-response') { + handleUserResponse(message); + sendResponse({ success: true }); + return false; + } + + /* Sync session from Popup to Background */ + if (message.type === 'sync-session') { + VaultManager.unlock(message.password) + .then(() => sendResponse({ success: true })) + .catch(err => { + console.error('[Lumen Background] Session sync failed:', err); + sendResponse({ error: err.message }); + }); + return true; + } + + /* Sync active wallet selection from UI */ + if (message.type === 'sync-active-wallet') { + if (message.address) { + chrome.storage.local.set({ [STORAGE_ACTIVE_WALLET]: message.address }) + .then(() => sendResponse({ success: true })) + .catch(err => sendResponse({ error: err.message })); + return true; + } + sendResponse({ error: 'Missing address' }); + return false; + } + + return false; + }); +} + +if (chrome?.runtime?.onStartup) { + chrome.runtime.onStartup.addListener(() => { + prunePendingQueue().catch(() => { + }); + }); +} + +if (chrome?.runtime?.onInstalled) { + chrome.runtime.onInstalled.addListener(() => { + prunePendingQueue().catch(() => { + }); + }); +} + +// Port-based messaging for long-running requests (e.g. sign/approve) +if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onConnect) { + chrome.runtime.onConnect.addListener((port) => { + if (port.name !== 'lumen-provider') return; + + port.onMessage.addListener((message) => { + if (message?.type !== 'lumen-provider-request') return; + + handleProviderRequest(message, port.sender) + .then((response) => { + port.postMessage({ + requestId: message.requestId, + data: response?.data, + error: response?.error + }); + }) + .catch((error) => { + port.postMessage({ + requestId: message.requestId, + error: error?.message || 'Request failed' + }); + }); + }); + }); +} + +/** + * Process provider requests + * @param {Object} message - Request from content script + * @param {Object} sender - Chrome sender info (tab, frameId, etc.) + * @returns {Promise} Response object with data or error + */ +async function handleProviderRequest(message: any, sender: any): Promise { + const { method, params, origin } = message; + + try { + await prunePendingQueue(); + const ethMethods = new Set([ + 'eth_requestAccounts', + 'eth_accounts', + 'eth_chainId', + 'net_version', + 'eth_sendTransaction', + 'personal_sign', + 'eth_sign', + 'eth_signTypedData', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + 'wallet_addEthereumChain', + 'wallet_switchEthereumChain' + ]); + if (ethMethods.has(method)) { + throw new Error('Ethereum methods are disabled in this Cosmos-only wallet.'); + } + + switch (method) { + // Wallet connection/enable request + case 'eth_requestAccounts': + case 'enable': + return await handleEnable(origin, sender.tab); + + // Get current accounts + case 'eth_accounts': + return await handleGetAccounts(origin); + + // Chain ID request + case 'eth_chainId': + case 'net_version': + return await handleGetChainId(); + + // Sign transaction + case 'eth_sendTransaction': + return await handleSendTransaction(params, origin, sender.tab); + + // Sign message + case 'personal_sign': + case 'eth_sign': + return await handleSignMessage(params, origin, sender.tab); + + // Sign typed data (EIP-712) + case 'eth_signTypedData': + case 'eth_signTypedData_v3': + case 'eth_signTypedData_v4': + return await handleSignTypedData(params, origin, sender.tab); + + // Add network/chain + case 'wallet_addEthereumChain': + return await handleAddChain(params, origin, sender.tab); + + // Switch network/chain + case 'wallet_switchEthereumChain': + return await handleSwitchChain(params, origin, sender.tab); + + // Cosmos-specific methods (for Lumen chain) + case 'cosmos_getKey': + case 'getKey': + return await handleCosmosGetKey(params?.chainId, origin); + + case 'cosmos_signAmino': + case 'signAmino': + return await handleCosmosSignAmino(params, origin, sender.tab); + + case 'cosmos_signDirect': + case 'signDirect': + return await handleCosmosSignDirect(params, origin, sender.tab); + + case 'experimentalSuggestChain': + return { data: null }; + + default: + throw new Error(`Unsupported method: ${method}`); + } + } catch (error: any) { + console.error(`[Lumen Background] Error handling ${method}:`, error); + return { error: error.message }; + } +} + +/** + * PLACEHOLDER HANDLERS + * These should be implemented with actual wallet logic + */ + +/** + * Store for pending connection requests + * Key: requestId, Value: { resolve, reject, origin, type } + */ +const pendRequest = new Map void; + reject: (error: any) => void; + origin: string; + type: 'enable' | 'sign' | 'transaction'; +}>(); + +const REQUEST_TIMEOUT_MS = 60 * 1000; +type PendingRequestData = { + requestId: string; + origin: string; + permissions: string[]; + type: 'approval-request' | 'pending-unlock-request' | 'transaction-request'; + params?: any; + timestamp?: number; +}; + +let isPopupOpening = false; +let isPanelOpening = false; +let popupWindowId: number | null = null; +const STORAGE_POPUP_WINDOW_ID = 'lumen_popup_window_id'; + +const STORAGE_ACTIVE_WALLET = 'activeWalletAddress'; +const STORAGE_CONNECTED_ORIGINS = 'connectedOrigins'; +const STORAGE_PENDING_QUEUE = 'pendingApprovalQueue'; + +const originToPattern = (origin: string): string | null => { + try { + const url = new URL(origin); + return `${url.origin}/*`; + } catch { + return null; + } +}; + +async function ensureSitePermission(origin: string): Promise { + if (!chrome.permissions) return; + const pattern = originToPattern(origin); + if (!pattern) return; + const perms = { origins: [pattern] }; + const granted = await chrome.permissions.contains(perms); + if (!granted) { + throw new Error('Site permission not granted.'); + } +} + +async function getActiveWalletAddress(): Promise { + try { + const result = await chrome.storage.local.get([STORAGE_ACTIVE_WALLET]) as { activeWalletAddress?: string }; + return result.activeWalletAddress || null; + } catch { + return null; + } +} + +async function getPendingQueue(): Promise { + const result = await chrome.storage.local.get(STORAGE_PENDING_QUEUE) as { pendingApprovalQueue?: PendingRequestData[] }; + return Array.isArray(result.pendingApprovalQueue) ? result.pendingApprovalQueue : []; +} + +async function setPendingQueue(queue: PendingRequestData[]): Promise { + await chrome.storage.local.set({ [STORAGE_PENDING_QUEUE]: queue }); + const count = queue.length; + if (chrome.action && chrome.action.setBadgeText) { + await chrome.action.setBadgeText({ text: count > 0 ? String(count) : '' }); + await chrome.action.setBadgeBackgroundColor({ color: count > 0 ? '#f5d996ff' : '#00000000' }); + await chrome.action.setTitle({ + title: count > 0 ? `Lumen Wallet (${count} pending request${count > 1 ? 's' : ''})` : 'Lumen Wallet' + }); + } +} + +async function prunePendingQueue(): Promise { + const queue = await getPendingQueue(); + if (!queue.length) return; + const now = Date.now(); + const fresh: PendingRequestData[] = []; + for (const item of queue) { + const timestamp = item.timestamp ?? 0; + const isStale = timestamp > 0 && now - timestamp > REQUEST_TIMEOUT_MS; + if (isStale) { + const pending = pendRequest.get(item.requestId); + if (pending) { + pending.reject(new Error('Request timeout')); + pendRequest.delete(item.requestId); + } + continue; + } + fresh.push(item); + } + if (fresh.length !== queue.length) { + await setPendingQueue(fresh); + } +} + +async function enqueuePendingRequest(requestData: PendingRequestData): Promise { + await prunePendingQueue(); + const queue = await getPendingQueue(); + const nextQueue = [requestData, ...queue.filter((item) => item.requestId !== requestData.requestId)]; + await setPendingQueue(nextQueue); +} + +async function removePendingRequest(requestId: string): Promise { + const queue = await getPendingQueue(); + const nextQueue = queue.filter((item) => item.requestId !== requestId); + await setPendingQueue(nextQueue); +} + +async function openApprovalPopup(tab?: chrome.tabs.Tab) { + if (typeof chrome === 'undefined' || !chrome.windows) return; + + // Prevent race conditions + if (isPopupOpening || isPanelOpening) return; + + try { + // Prefer side panel for consistency + if (chrome.sidePanel?.open) { + isPanelOpening = true; + try { + if (tab?.id !== undefined) { + await chrome.sidePanel.open({ tabId: tab.id }); + return; + } + const win = await chrome.windows.getCurrent(); + if (win?.id) { + await chrome.sidePanel.open({ windowId: win.id }); + return; + } + } catch (e) { + } finally { + setTimeout(() => { + isPanelOpening = false; + }, 500); + } + } + + isPopupOpening = true; + // Try to re-focus an existing approval popup (avoid duplicates) + let storedId: number | null = popupWindowId; + if (chrome.storage?.session) { + const stored = await chrome.storage.session.get(STORAGE_POPUP_WINDOW_ID) as { lumen_popup_window_id?: number }; + storedId = stored?.lumen_popup_window_id ?? storedId; + } + if (storedId) { + try { + const win = await chrome.windows.get(storedId); + if (win?.id) { + popupWindowId = win.id; + await chrome.windows.update(win.id, { focused: true }); + return; + } + } catch { + popupWindowId = null; + if (chrome.storage?.session) { + await chrome.storage.session.remove(STORAGE_POPUP_WINDOW_ID); + } + } + } + + const windows = await chrome.windows.getAll({ populate: true, windowTypes: ['popup'] }); + const existing = windows.find(w => w.tabs?.some(t => t.url?.includes('index.html'))); + if (existing?.id) { + popupWindowId = existing.id; + if (chrome.storage?.session) { + await chrome.storage.session.set({ [STORAGE_POPUP_WINDOW_ID]: existing.id }); + } + await chrome.windows.update(existing.id, { focused: true }); + return; + } + + // Attempt native action popup first (may fail without user gesture) + if (chrome.action?.openPopup) { + try { + await chrome.action.openPopup(); + return; + } catch (e) { + } + } + + // Fallback: create a dedicated popup window + const created = await chrome.windows.create({ + url: chrome.runtime.getURL('index.html'), + type: 'popup', + width: 360, + height: 600 + }); + if (created?.id) { + popupWindowId = created.id; + if (chrome.storage?.session) { + await chrome.storage.session.set({ [STORAGE_POPUP_WINDOW_ID]: created.id }); + } + } + } catch (e) { + console.error('[Lumen] Failed to open popup:', e); + } finally { + // Release lock after short delay to ensure window is registered + setTimeout(() => { + isPopupOpening = false; + }, 1000); + } +} + +if (typeof chrome !== 'undefined' && chrome.windows?.onRemoved) { + chrome.windows.onRemoved.addListener((windowId) => { + if (popupWindowId && windowId === popupWindowId) { + popupWindowId = null; + chrome.storage.session.remove(STORAGE_POPUP_WINDOW_ID).catch(() => { + }); + } + }); +} + +function generateRequestId(): string { + return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +async function handleEnable(origin: string, _tab: any) { + + try { + // Check state + const isLocked = await checkWalletLocked(); + const connectedOrigins = await getConnectedOrigins(); + const isConnected = connectedOrigins.includes(origin); + + // If UNLOCKED and CONNECTED, return address immediately + if (!isLocked && isConnected) { + await ensureHostPermissions(); + const address = await getWalletAddress(); + if (!address) { + throw new Error('No wallet found'); + } + return { data: [address] }; + } + + // Need user interaction (unlock OR approval OR both) + // Show badge notification instead of opening popup + await prunePendingQueue(); + const requestId = generateRequestId(); + + // Store pending request + const requestData: PendingRequestData = { + requestId, + origin, + permissions: ['View wallet address', 'Request transaction signatures'], + type: isLocked ? 'pending-unlock-request' : 'approval-request', + timestamp: Date.now() + }; + + await enqueuePendingRequest(requestData); + + await openApprovalPopup(_tab); + + return new Promise((resolve, reject) => { + pendRequest.set(requestId, { resolve, reject, origin, type: 'enable' }); + + // Timeout after 5 minutes + setTimeout(() => { + if (pendRequest.has(requestId)) { + pendRequest.delete(requestId); + removePendingRequest(requestId).catch(() => { + }); + reject(new Error('Request timeout')); + } + }, REQUEST_TIMEOUT_MS); + }); + + } catch (error: any) { + console.error('[Lumen] Enable error:', error); + return { error: error.message || 'Failed to connect wallet' }; + } +} + +function handleUserResponse(message: any) { + const { requestId, approved } = message; + const pending = pendRequest.get(requestId); + + if (!pending) { + removePendingRequest(requestId).catch(() => { + }); + return; + } + + pendRequest.delete(requestId); + + removePendingRequest(requestId).catch(() => { + }); + + if (approved) { + (async () => { + try { + if (pending.type === 'enable') { + await ensureSitePermission(pending.origin); + await ensureHostPermissions(); + } + await addConnectedOrigin(pending.origin); + const address = await getWalletAddress(); + if (!address) { + throw new Error('No wallet found'); + } + pending.resolve({ data: [address] }); + } catch (error) { + await removeConnectedOrigin(pending.origin); + pending.reject(error); + } + })().catch((error) => pending.reject(error)); + } else { + pending.reject(new Error('User rejected the request')); + } +} + +if (chrome.permissions?.onRemoved) { + chrome.permissions.onRemoved.addListener(({ origins }) => { + if (!origins || origins.length === 0) return; + origins.forEach((originPattern) => { + const origin = originPattern.replace(/\/\*$/, ''); + removeConnectedOrigin(origin).catch(() => { + }); + }); + }); +} + +/** + * Helper functions for wallet state management + */ + +async function checkWalletLocked(): Promise { + try { + // Check if vault exists + const hasVault = await VaultManager.hasWallet(); + if (!hasVault) { + return true; // No vault = locked/needs setup + } + + // Check if session is expired + const isExpired = await VaultManager.isSessionExpired(); + return isExpired; // If session expired, wallet is locked + } catch (error) { + console.error('[Lumen] Error checking wallet lock status:', error); + return true; // On error, assume locked for safety + } +} + +async function ensureHostPermissions(): Promise { + if (typeof chrome === 'undefined' || !chrome.permissions) return; + + const perms = { origins: REQUIRED_HOST_PERMISSIONS }; + const granted = await chrome.permissions.contains(perms); + if (!granted) { + throw new Error('Host permissions not granted.'); + } +} + +// Pending queue pruning is handled in enqueuePendingRequest / prunePendingQueue + +async function getWalletAddress(): Promise { + try { + // Get wallets from vault (requires active session) + const wallets = await VaultManager.getWallets(); + + if (!wallets || wallets.length === 0) { + return null; + } + + const activeAddress = await getActiveWalletAddress(); + if (activeAddress) { + const match = wallets.find(w => w.address === activeAddress); + if (match) return match.address; + } + + // Fallback to first wallet + return wallets[0].address; + } catch (error: any) { + // Don't log error if it's just session expiration (expected when locked) + if (!error.message?.includes('Session expired')) { + console.error('[Lumen] Error getting wallet address:', error); + } + return null; + } +} + +async function getConnectedOrigins(): Promise { + const result = await chrome.storage.local.get([STORAGE_CONNECTED_ORIGINS]) as { connectedOrigins?: string[] }; + return result.connectedOrigins || []; +} + +async function addConnectedOrigin(origin: string): Promise { + const origins = await getConnectedOrigins(); + if (!origins.includes(origin)) { + origins.push(origin); + await chrome.storage.local.set({ [STORAGE_CONNECTED_ORIGINS]: origins }); + } +} + +async function removeConnectedOrigin(origin: string): Promise { + const origins = await getConnectedOrigins(); + const next = origins.filter((o) => o !== origin); + if (next.length !== origins.length) { + await chrome.storage.local.set({ [STORAGE_CONNECTED_ORIGINS]: next }); + } +} + +// openSidePanel function removed - now using badge notification + + +async function handleGetAccounts(origin: string) { + // Check if origin is connected + const connectedOrigins = await getConnectedOrigins(); + + if (!connectedOrigins.includes(origin)) { + // Not connected, return empty array + return { + data: [] + }; + } + + // Connected, return wallet address + const address = await getWalletAddress(); + + return { + data: address ? [address] : [] + }; +} + +async function handleGetChainId() { + // Return Lumen chain ID + return { + data: 'lumen' // Lumen chain ID + }; +} + +async function handleSendTransaction(params: any, origin: string, _tab: any) { + + await prunePendingQueue(); + const requestId = generateRequestId(); + + // Store pending request + const requestData: PendingRequestData = { + requestId, + origin, + permissions: ['Request transaction signatures'], + type: 'transaction-request', + params, + timestamp: Date.now() + }; + + await enqueuePendingRequest(requestData); + chrome.runtime.sendMessage({ type: 'show-approval' }).catch(() => { + }); + + await openApprovalPopup(_tab); + + // Wait for user approval + await new Promise((resolve, reject) => { + pendRequest.set(requestId, { resolve, reject, origin, type: 'transaction' }); + + setTimeout(() => { + if (pendRequest.has(requestId)) { + pendRequest.delete(requestId); + removePendingRequest(requestId).catch(() => { + }); + reject(new Error('Request timeout')); + } + }, REQUEST_TIMEOUT_MS); + }); + + // Handle Generic/EVM Transaction Signing (Placeholder) + return { error: 'Signing implementation pending for generic transactions' }; +} + +async function handleSignMessage(params: any, _origin: string, _tab: any) { + void params; + void _origin; + void _tab; + // TODO: Open popup for user to review and sign message + // TODO: Sign with private key + // TODO: Return signature + + throw new Error('Message signing not yet implemented'); +} + +async function handleSignTypedData(params: any, _origin: string, _tab: any) { + void params; + void _origin; + void _tab; + // TODO: Parse and validate EIP-712 typed data + // TODO: Open popup for user to review + // TODO: Sign and return signature + + throw new Error('Typed data signing not yet implemented'); +} + +async function handleAddChain(params: any, _origin: string, _tab: any) { + void params; + void _origin; + void _tab; + // TODO: Validate chain parameters + // TODO: Prompt user to add chain + // TODO: Save to storage if approved + + return { data: null }; +} + +async function handleSwitchChain(params: any, _origin: string, _tab: any) { + void params; + void _origin; + void _tab; + // TODO: Check if chain exists + // TODO: Switch active chain + // TODO: Emit chainChanged event + + throw new Error('Chain switching not yet implemented'); +} + +// Cosmos-specific handlers for Lumen chain +async function handleCosmosGetKey(chainId: string, origin?: string) { + if (chainId && chainId !== CHAIN_ID) { + throw new Error('Unsupported chain'); + } + + if (origin) { + const connectedOrigins = await getConnectedOrigins(); + if (!connectedOrigins.includes(origin)) { + throw new Error('Not connected. Call enable() first.'); + } + } + + const wallets = await VaultManager.getWallets(); + if (!wallets || wallets.length === 0) { + throw new Error('Wallet is locked or empty'); + } + + try { + const activeAddress = await getActiveWalletAddress(); + const walletData = activeAddress + ? wallets.find(w => w.address === activeAddress) || wallets[0] + : wallets[0]; + const wallet = await Secp256k1HdWallet.fromMnemonic(walletData.mnemonic, { prefix: 'lmn' }); + const accounts = await wallet.getAccounts(); + const account = accounts[0]; + + // Decode bech32 address to bytes for dApp compatibility + const addressBytes = fromBech32(account.address).data; + + return { + data: { + name: 'Lumen Wallet', + algo: account.algo, + pubKey: account.pubkey, + address: addressBytes, + bech32Address: account.address, + isNanoLedger: false + } + }; + } catch (error: any) { + console.error('[Lumen] getKey error:', error); + throw new Error(error.message || 'Failed to retrieve key'); + } +} + +async function handleCosmosSignAmino(params: any, origin: string, _tab: any) { + + await prunePendingQueue(); + const requestId = generateRequestId(); + + const requestData: PendingRequestData = { + requestId, + origin, + permissions: ['Request transaction signatures'], + type: 'transaction-request', + params: { ...params, mode: 'amino' }, + timestamp: Date.now() + }; + + await enqueuePendingRequest(requestData); + chrome.runtime.sendMessage({ type: 'show-approval' }).catch(() => { + }); + + await openApprovalPopup(_tab); + + // Wait for user approval + await new Promise((resolve, reject) => { + pendRequest.set(requestId, { resolve, reject, origin, type: 'transaction' }); + + setTimeout(() => { + if (pendRequest.has(requestId)) { + pendRequest.delete(requestId); + removePendingRequest(requestId).catch(() => { + }); + reject(new Error('Request timeout')); + } + }, REQUEST_TIMEOUT_MS); + }); + + // User approved, sign logic + try { + const wallets = await VaultManager.getWallets(); + const signerAddress = params?.signerAddress; + const signDoc = params?.signDoc; + + if (!signerAddress || !signDoc) { + throw new Error('Missing signerAddress or signDoc'); + } + + // Find wallet + const walletData = wallets.find(w => w.address === signerAddress); + if (!walletData) throw new Error('Signer address not found in wallet'); + + const wallet = await Secp256k1HdWallet.fromMnemonic(walletData.mnemonic, { prefix: 'lmn' }); + const result = await wallet.signAmino(signerAddress, signDoc); + + const [account] = await wallet.getAccounts(); + if (account.address !== signerAddress) { + throw new Error('Signer address mismatch'); + } + + const { pqcPrivKey, pqcPubKey } = getPqcKeys(walletData); + + const chainId = signDoc.chain_id || signDoc.chainId || CHAIN_ID; + const accountNumber = toNumber(signDoc.account_number ?? signDoc.accountNumber, 'account_number'); + + const signedDoc = result?.signed || signDoc; + const fee = signedDoc.fee; + if (!fee) throw new Error('Missing fee in signDoc'); + + const gasLimit = toNumber(fee.gas, 'fee.gas'); + const sequence = toNumber(signedDoc.sequence ?? signDoc.sequence, 'sequence'); + + const memo = signedDoc.memo ?? signDoc.memo ?? ''; + const timeoutHeightRaw = + signedDoc.timeout_height ?? + (signedDoc as any).timeoutHeight ?? + signDoc.timeout_height ?? + (signDoc as any).timeoutHeight; + const timeoutHeight = timeoutHeightRaw !== undefined ? toNumber(timeoutHeightRaw, 'timeout_height') : undefined; + + const aminoMsgs = signedDoc.msgs ?? signDoc.msgs ?? []; + const protoMsgs = aminoMsgs.map((msg: any) => defaultAminoTypes.fromAmino(msg)); + + const txBodyValue: any = { + messages: protoMsgs, + memo: memo + }; + if (timeoutHeight !== undefined && timeoutHeight !== 0) { + txBodyValue.timeoutHeight = timeoutHeight; + } + + const txBodyBytes = defaultRegistry.encode({ + typeUrl: '/cosmos.tx.v1beta1.TxBody', + value: txBodyValue + }); + + const pubkey = encodePubkey(getAminoPubkey(account)); + const authInfoBytes = makeAuthInfoBytes( + [{ pubkey, sequence }], + fee.amount, + gasLimit, + fee.granter, + fee.payer, + SignMode.SIGN_MODE_LEGACY_AMINO_JSON + ); + + const tempTxRaw = { + bodyBytes: txBodyBytes, + authInfoBytes: authInfoBytes, + signatures: [] + }; + + // @ts-ignore + const pqcPayload = LumenSDK.pqc.computeSignBytes(chainId, accountNumber, tempTxRaw); + // @ts-ignore + const pqcSigRaw = await LumenSDK.pqc.signDilithium(pqcPayload, pqcPrivKey); + + const pqcEntry = { + addr: walletData.address, + scheme: 'dilithium3', + signature: new Uint8Array(pqcSigRaw), + pubKey: pqcPubKey + }; + + // @ts-ignore + const finalTxBodyBytes = LumenSDK.pqc.withPqcExtension(txBodyBytes, [pqcEntry]); + + const pqcTxRaw = TxRaw.fromPartial({ + bodyBytes: finalTxBodyBytes, + authInfoBytes: authInfoBytes, + signatures: [fromBase64(result.signature.signature)] + }); + const pqcTxRawBytes = TxRaw.encode(pqcTxRaw).finish(); + + return { + data: { + ...result, + pqc: { + bodyBytes: finalTxBodyBytes, + authInfoBytes: authInfoBytes, + txRawBytes: pqcTxRawBytes + } + } + }; + } catch (error: any) { + console.error('SignAmino failed:', error); + throw new Error(error.message || 'Signing failed'); + } +} + +async function handleCosmosSignDirect(params: any, origin: string, _tab: any) { + + await prunePendingQueue(); + const requestId = generateRequestId(); + + const requestData: PendingRequestData = { + requestId, + origin, + permissions: ['Request transaction signatures'], + type: 'transaction-request', + params: { ...params, mode: 'direct' }, + timestamp: Date.now() + }; + + await enqueuePendingRequest(requestData); + + await openApprovalPopup(_tab); + + // Wait for user approval + await new Promise((resolve, reject) => { + pendRequest.set(requestId, { resolve, reject, origin, type: 'transaction' }); + + setTimeout(() => { + if (pendRequest.has(requestId)) { + pendRequest.delete(requestId); + removePendingRequest(requestId).catch(() => { + }); + reject(new Error('Request timeout')); + } + }, REQUEST_TIMEOUT_MS); + }); + + // User approved, sign logic + try { + const wallets = await VaultManager.getWallets(); + const signerAddress = params?.signerAddress; + const signDoc = params?.signDoc; + + if (!signerAddress || !signDoc) { + throw new Error('Missing signerAddress or signDoc'); + } + + // Find wallet + const walletData = wallets.find(w => w.address === signerAddress); + if (!walletData) throw new Error('Signer address not found in wallet'); + + // Create signer + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(walletData.mnemonic, { prefix: 'lmn' }); + + const { pqcPrivKey, pqcPubKey } = getPqcKeys(walletData); + + const chainId = signDoc.chainId || CHAIN_ID; + const accountNumber = toNumber(signDoc.accountNumber, 'accountNumber'); + + const bodyBytes = ensureUint8Array(signDoc.bodyBytes); + const authInfoBytes = ensureUint8Array(signDoc.authInfoBytes); + + const tempTxRaw = { + bodyBytes: bodyBytes, + authInfoBytes: authInfoBytes, + signatures: [] + }; + + // @ts-ignore + const pqcPayload = LumenSDK.pqc.computeSignBytes(chainId, accountNumber, tempTxRaw); + // @ts-ignore + const pqcSigRaw = await LumenSDK.pqc.signDilithium(pqcPayload, pqcPrivKey); + + const pqcEntry = { + addr: walletData.address, + scheme: 'dilithium3', + signature: new Uint8Array(pqcSigRaw), + pubKey: pqcPubKey + }; + + // @ts-ignore + const finalTxBodyBytes = LumenSDK.pqc.withPqcExtension(bodyBytes, [pqcEntry]); + + const signDocWithPqc = { + ...signDoc, + bodyBytes: finalTxBodyBytes, + authInfoBytes: authInfoBytes, + chainId: chainId + }; + + // Sign + const result = await wallet.signDirect(signerAddress, signDocWithPqc); + const signedBodyBytes = result?.signed?.bodyBytes; + const signedAuthInfoBytes = result?.signed?.authInfoBytes; + const normalizedResult = { + ...result, + signed: { + ...result.signed, + bodyBytes: signedBodyBytes ? Buffer.from(signedBodyBytes).toString('base64') : '', + authInfoBytes: signedAuthInfoBytes ? Buffer.from(signedAuthInfoBytes).toString('base64') : '' + } + }; + + return { data: normalizedResult }; + } catch (error: any) { + console.error('Signing failed:', error); + throw new Error(error.message || 'Signing failed'); + } +} + +/** + * Emit events to connected tabs + * Call this when accounts or chain changes + */ +/* Commented out until needed - will use when implementing event emission +async function emitProviderEvent(eventName: string, data: any) { + // TODO: Get all connected tabs + // TODO: Send message to content scripts in those tabs +} +*/ diff --git a/src/components/ApprovalModal.tsx b/src/components/ApprovalModal.tsx new file mode 100644 index 0000000..633ed22 --- /dev/null +++ b/src/components/ApprovalModal.tsx @@ -0,0 +1,420 @@ +import { useState, useEffect } from 'react'; +import { VaultManager } from '../modules/vault/vault'; +import { originToPattern } from '../permissions'; + +interface ApprovalRequest { + requestId: string; + origin: string; + permissions: string[]; + type: 'approval-request' | 'pending-unlock-request' | 'transaction-request'; + params?: any; +} + +interface ApprovalModalProps { + onClose: () => void; +} + +export function ApprovalModal({ onClose }: ApprovalModalProps) { + const [pendingQueue, setPendingQueue] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [walletAddress, setWalletAddress] = useState(''); + const [loading, setLoading] = useState(true); + const [isLocked, setIsLocked] = useState(false); + const [permissionError, setPermissionError] = useState(null); + const STORAGE_ACTIVE_WALLET = 'activeWalletAddress'; + + useEffect(() => { + loadPendingQueue(); + }, []); + + useEffect(() => { + const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }, areaName: string) => { + if (areaName !== 'local') return; + if (!changes.pendingApprovalQueue) return; + const nextValue = changes.pendingApprovalQueue.newValue; + const nextQueue = Array.isArray(nextValue) ? nextValue : []; + setPendingQueue(nextQueue); + }; + chrome.storage.onChanged.addListener(handleStorageChange); + return () => chrome.storage.onChanged.removeListener(handleStorageChange); + }, []); + + useEffect(() => { + if (currentIndex >= pendingQueue.length && pendingQueue.length > 0) { + setCurrentIndex(pendingQueue.length - 1); + } + }, [currentIndex, pendingQueue.length]); + + useEffect(() => { + if (pendingQueue.length > 0) { + loadWalletInfo(); + } + }, [pendingQueue.length]); + + const loadPendingQueue = async () => { + try { + const result = await chrome.storage.local.get('pendingApprovalQueue'); + const queue = Array.isArray(result.pendingApprovalQueue) ? result.pendingApprovalQueue : []; + if (queue.length > 0) { + setPendingQueue(queue as ApprovalRequest[]); + await loadWalletInfo(); + } else { + setLoading(false); + } + } catch (error) { + console.error('[ApprovalModal] Error checking pending request:', error); + setLoading(false); + } + }; + + const loadWalletInfo = async () => { + try { + const hasWallet = await VaultManager.hasWallet(); + if (!hasWallet) { + setLoading(false); + return; + } + + const expired = await VaultManager.isSessionExpired(); + if (expired) { + setIsLocked(true); + setLoading(false); + return; + } + + const wallets = await VaultManager.getWallets(); + if (wallets && wallets.length > 0) { + const result = await chrome.storage.local.get(STORAGE_ACTIVE_WALLET) as { activeWalletAddress?: string }; + const activeWallet = + wallets.find(w => w.address === result.activeWalletAddress) || wallets[0]; + setWalletAddress(activeWallet.address); + } + } catch (error) { + console.error('[ApprovalModal] Failed to load wallet:', error); + } finally { + setLoading(false); + } + }; + + const pendingRequest = pendingQueue[currentIndex] || null; + const totalCount = pendingQueue.length; + + const handleApprove = async () => { + if (!pendingRequest) return; + + setPermissionError(null); + if (pendingRequest.type === 'approval-request' && chrome.permissions) { + const pattern = originToPattern(pendingRequest.origin); + if (pattern) { + const granted = await chrome.permissions.contains({ origins: [pattern] }); + if (!granted) { + const ok = await chrome.permissions.request({ origins: [pattern] }); + if (!ok) { + setPermissionError('Site permission denied.'); + return; + } + } + } + } + + chrome.runtime.sendMessage({ + type: 'user-response', + requestId: pendingRequest.requestId, + approved: true + }); + + const nextQueue = pendingQueue.filter((_, idx) => idx !== currentIndex); + setPendingQueue(nextQueue); + if (nextQueue.length === 0) { + onClose(); + setTimeout(() => { + window.close(); + }, 50); + } else if (currentIndex >= nextQueue.length) { + setCurrentIndex(nextQueue.length - 1); + } + }; + + const handleReject = () => { + if (!pendingRequest) return; + + chrome.runtime.sendMessage({ + type: 'user-response', + requestId: pendingRequest.requestId, + approved: false + }); + + const nextQueue = pendingQueue.filter((_, idx) => idx !== currentIndex); + setPendingQueue(nextQueue); + if (nextQueue.length === 0) { + onClose(); + setTimeout(() => { + window.close(); + }, 50); + } else if (currentIndex >= nextQueue.length) { + setCurrentIndex(nextQueue.length - 1); + } + }; + + const copyAddress = () => { + navigator.clipboard.writeText(walletAddress); + }; + + const handlePrev = () => { + if (currentIndex > 0) { + setCurrentIndex(currentIndex - 1); + } + }; + + const handleNext = () => { + if (currentIndex < totalCount - 1) { + setCurrentIndex(currentIndex + 1); + } + }; + + // No pending request - don't show modal + if (!loading && !pendingRequest) { + onClose(); + return null; + } + + // Extract domain from origin + let displayOrigin = pendingRequest?.origin || ''; + try { + if (pendingRequest?.origin) { + const url = new URL(pendingRequest.origin); + displayOrigin = url.hostname; + } + } catch { + // Use origin as-is if not a valid URL + } + + const isTransaction = pendingRequest?.type === 'transaction-request'; + const txParams = pendingRequest?.params || {}; + // Try to extract messages for display + const msgs = txParams.msgs || txParams.messages || []; + + // Attempt to extract key info for standard Cosmos messages + const getTransactionSummary = () => { + if (!msgs || msgs.length === 0) return null; + + const firstMsg = msgs[0]; + const typeUrl = firstMsg.typeUrl || firstMsg.type || 'Transaction'; + // Extract value - handle both Amino (value) and Direct (encoded in value or separate) format differences slightly generally + // For Direct, value is bytes, so we might not be able to decode easily without proto. + // But if it came from our dApp provider, it might be in a readable format before signing. + const value = firstMsg.value || firstMsg; + + return { + type: typeUrl.split('.').pop().replace('Msg', ''), + to: value.toAddress || value.to_address || '-', + amount: value.amount ? ( + Array.isArray(value.amount) + ? `${value.amount[0]?.amount} ${value.amount[0]?.denom}` + : `${value.amount?.amount} ${value.amount?.denom}` + ) : '-' + }; + }; + + const txSummary = isTransaction ? getTransactionSummary() : null; + + return ( +
+ {/* Dark overlay */} +
+ + {/* Modal content */} +
+ {loading ? ( +
+
+
+

Loading...

+
+
+ ) : isLocked ? ( +
+
+ + + +
+

Wallet Locked

+

+ Please unlock your wallet first to approve this connection. +

+ +
+ ) : ( + <> + {/* Header */} +
+
+
+ + + +
+
+

+ {isTransaction ? 'Sign Transaction' : 'Connect Request'} +

+

+ {displayOrigin} +

+
+
+ + {totalCount > 0 ? `${currentIndex + 1}/${totalCount}` : '0/0'} + +
+ + +
+
+
+
+ {permissionError && ( +
+
+ {permissionError} +
+
+ )} + + {/* Content */} +
+ {!isTransaction ? ( + <> +
+ This site is requesting your permission to: +
+ + {/* Permissions List */} +
+

Permissions

+
    + {(pendingRequest?.permissions ?? []).map((permission, index) => ( +
  • + + + + {permission} +
  • + ))} +
+
+ +
+
Network access required
+ Chrome may ask to allow network access to Lumen RPC/REST endpoints. If denied, the connection will fail. +
+ + ) : ( +
+ {txSummary ? ( +
+
+ Type + {txSummary.type} +
+
+ To +
{txSummary.to}
+
+
+ Amount +
{txSummary.amount}
+
+
+ ) : ( +
+ Detailed preview not available for this message type. +
+ )} + +
+
+ View Raw Data +
+                                                {JSON.stringify(msgs, null, 2)}
+                                            
+
+
+
+ )} + + {/* Wallet Info (Only for context) */} + {walletAddress && ( +
+

Your Wallet

+
+
+ {walletAddress.slice(0, 10)}...{walletAddress.slice(-6)} +
+ +
+
+ )} + + {/* Warning */} + {!isTransaction && ( +
+ + + +

+ Only connect with sites you trust. +

+
+ )} +
+ + {/* Actions */} +
+ + +
+ + )} +
+
+ ); +} diff --git a/src/components/ApprovalPage.tsx b/src/components/ApprovalPage.tsx new file mode 100644 index 0000000..10fd256 --- /dev/null +++ b/src/components/ApprovalPage.tsx @@ -0,0 +1,258 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { VaultManager } from '../modules/vault/vault'; +import { originToPattern } from '../permissions'; + +interface ApprovalRequest { + requestId: string; + origin: string; + permissions: string[]; + type: 'approval-request' | 'pending-unlock-request'; +} + +export function ApprovalPage() { + const navigate = useNavigate(); + const [pendingQueue, setPendingQueue] = useState([]); + const [walletAddress, setWalletAddress] = useState(''); + const [balance, setBalance] = useState('0.00'); + const [loading, setLoading] = useState(true); + const [permissionError, setPermissionError] = useState(null); + const STORAGE_ACTIVE_WALLET = 'activeWalletAddress'; + + useEffect(() => { + // Check for pending approval request in storage + const checkPendingRequest = async () => { + try { + const result = await chrome.storage.local.get('pendingApprovalQueue'); + const queue = Array.isArray(result.pendingApprovalQueue) ? result.pendingApprovalQueue : []; + if (queue.length > 0) { + setPendingQueue(queue as ApprovalRequest[]); + } else { + } + } catch (error) { + console.error('[ApprovalPage] Error checking pending request:', error); + } + }; + + checkPendingRequest(); + + // Load wallet info + loadWalletInfo(); + }, []); + + const loadWalletInfo = async () => { + try { + // Check if wallet is locked first + const hasWallet = await VaultManager.hasWallet(); + if (!hasWallet) { + setLoading(false); + return; + } + + const expired = await VaultManager.isSessionExpired(); + if (expired) { + // Wallet is locked, don't try to load wallet info + setLoading(false); + return; + } + + // Wallet is unlocked, safe to load + const wallets = await VaultManager.getWallets(); + if (wallets && wallets.length > 0) { + const result = await chrome.storage.local.get(STORAGE_ACTIVE_WALLET) as { activeWalletAddress?: string }; + const activeWallet = + wallets.find(w => w.address === result.activeWalletAddress) || wallets[0]; + setWalletAddress(activeWallet.address); + + // TODO: Fetch balance from chain + // For now, use placeholder + setBalance('1,234.56'); + } + } catch (error) { + console.error('[ApprovalPage] Failed to load wallet:', error); + } finally { + setLoading(false); + } + }; + + const pendingRequest = pendingQueue[0] || null; + + const handleApprove = async () => { + if (!pendingRequest) return; + + setPermissionError(null); + if (pendingRequest.type === 'approval-request' && chrome.permissions) { + const pattern = originToPattern(pendingRequest.origin); + if (pattern) { + const granted = await chrome.permissions.contains({ origins: [pattern] }); + if (!granted) { + const ok = await chrome.permissions.request({ origins: [pattern] }); + if (!ok) { + setPermissionError('Site permission denied.'); + return; + } + } + } + } + + chrome.runtime.sendMessage({ + type: 'user-response', + requestId: pendingRequest.requestId, + approved: true + }); + + // Close popup or navigate + window.close(); + }; + + const handleReject = () => { + if (!pendingRequest) return; + + chrome.runtime.sendMessage({ + type: 'user-response', + requestId: pendingRequest.requestId, + approved: false + }); + + // Close popup or navigate back + window.close(); + }; + + const copyAddress = () => { + navigator.clipboard.writeText(walletAddress); + }; + + if (loading) { + return ( +
+
+
+

Loading wallet...

+
+
+ ); + } + + if (!pendingRequest) { + return ( +
+
+

No pending approval request

+ +
+
+ ); + } + + // Extract domain from origin + let displayOrigin = pendingRequest.origin; + try { + const url = new URL(pendingRequest.origin); + displayOrigin = url.hostname; + } catch { + // Use origin as-is if not a valid URL + } + + return ( +
+ {/* Header */} +
+
+ {/* Simple globe icon as placeholder for favicon */} +
+ + + +
+
+

{displayOrigin}

+

wants to connect

+
+
+
+ + {/* Content */} +
+ {permissionError && ( +
+ {permissionError} +
+ )} + {/* Permissions */} +
+

This site will be able to:

+
    + {(pendingRequest.permissions ?? []).map((permission, index) => ( +
  • + + + + {permission} +
  • + ))} +
+
+ + {/* Wallet Info */} +
+

Your Wallet

+ + {/* Address */} +
+
+ {walletAddress.slice(0, 12)}...{walletAddress.slice(-8)} +
+ +
+ + {/* Balance */} +
+ + + + {balance} LUMEN +
+
+ + {/* Warning */} +
+ + + +

+ Only connect with sites you trust. Lumen Wallet will never ask for your private keys. +

+
+
+ + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 15de7b5..d68c970 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { VaultManager } from '../modules/vault/vault'; +import { NetworkManager, REST_PROVIDERS, RPC_PROVIDERS } from '../modules/sdk/network'; interface SettingsProps { onBack: () => void; @@ -9,22 +10,99 @@ export const Settings: React.FC = ({ onBack }) => { const [type, setType] = useState<'minute' | 'hour' | 'day'>('minute'); const [value, setValue] = useState(5); const [saved, setSaved] = useState(false); + const [connectedDApps, setConnectedDApps] = useState([]); + const [isAutoRpc, setIsAutoRpc] = useState(true); + const [selectedProvider, setSelectedProvider] = useState(null); + const [currentRest, setCurrentRest] = useState(''); + const [currentRpc, setCurrentRpc] = useState(''); useEffect(() => { const load = async () => { const current = await VaultManager.getLockSettings(); setType(current.type); setValue(current.value); + const nm = NetworkManager.getInstance(); + setIsAutoRpc(nm.isAutoMode()); + setSelectedProvider(nm.getSelectedProvider() === 'Auto' ? null : nm.getSelectedProvider()); + setCurrentRest(await nm.getRestEndpoint()); + setCurrentRpc(await nm.getRpcEndpoint()); + + // Load connected dApps + try { + const result = await chrome.storage.local.get(['connectedOrigins']); + const origins = (result.connectedOrigins as string[]) || []; + setConnectedDApps(origins); + } catch (e) { + } }; load(); }, []); + const handleToggleAuto = async () => { + const next = !isAutoRpc; + setIsAutoRpc(next); + const nm = NetworkManager.getInstance(); + if (next) { + nm.setAuto(true); + setSelectedProvider(null); + } else if (selectedProvider) { + nm.setManualProvider(selectedProvider); + } + setCurrentRest(await nm.getRestEndpoint(true)); + setCurrentRpc(await nm.getRpcEndpoint()); + }; + + const handleResetDefault = async () => { + const nm = NetworkManager.getInstance(); + nm.setAuto(true); + setIsAutoRpc(true); + setSelectedProvider(null); + try { + await chrome.storage.local.remove(['rpc_settings']); + } catch (e) { + } + setCurrentRest(REST_PROVIDERS[0]?.address || ''); + setCurrentRpc(RPC_PROVIDERS[0]?.address || ''); + }; + const handleSave = async () => { await VaultManager.setLockTimeout(type, value); + + const nm = NetworkManager.getInstance(); + if (isAutoRpc) { + nm.setAuto(true); + } else if (selectedProvider) { + nm.setManualProvider(selectedProvider); + } + await nm.saveSettings(); + setCurrentRest(await nm.getRestEndpoint(true)); + setCurrentRpc(await nm.getRpcEndpoint()); + setSaved(true); setTimeout(() => setSaved(false), 2000); }; + const handleDisconnect = async (origin: string) => { + try { + const result = await chrome.storage.local.get(['connectedOrigins']); + const origins = (result.connectedOrigins as string[]) || []; + const filtered = origins.filter(o => o !== origin); + await chrome.storage.local.set({ connectedOrigins: filtered }); + setConnectedDApps(filtered); + } catch (e) { + console.error('Error disconnecting dApp:', e); + } + }; + + const extractDomain = (origin: string): string => { + try { + const url = new URL(origin); + return url.hostname; + } catch { + return origin; + } + }; + return (
@@ -85,6 +163,110 @@ export const Settings: React.FC = ({ onBack }) => {
+
+
+ +

Connected Applications

+
+ +

+ Manage applications that have permission to view your account address. +

+ +
+ {connectedDApps.length === 0 ? ( +
+ No applications connected +
+ ) : ( +
+ {connectedDApps.map((origin) => ( +
+
+
+ + {extractDomain(origin).charAt(0).toUpperCase()} + +
+
+
+ {extractDomain(origin)} +
+
+ {origin} +
+
+
+ +
+ ))} +
+ )} +
+
+ +
+
+ +

Network Settings

+
+ +
+
+ Automatic RPC Selection + +
+ + {!isAutoRpc && ( +
+ + +
+ )} + +
+
Active RPC Endpoint
+
+ {currentRpc || RPC_PROVIDERS[0]?.address || 'Resolving...'} +
+
Active REST Endpoint
+
+ {currentRest || REST_PROVIDERS[0]?.address || 'Resolving...'} +
+ +
+
+
+
+
+
diff --git a/src/content-script.ts b/src/content-script.ts new file mode 100644 index 0000000..94c2df0 --- /dev/null +++ b/src/content-script.ts @@ -0,0 +1,105 @@ + +// This script bridges communication between the inpage script and the background script +(function () { + // Inject inpage.js + try { + const container = document.head || document.documentElement; + const scriptTag = document.createElement('script'); + scriptTag.src = chrome.runtime.getURL('inpage.js'); + scriptTag.onload = () => { + scriptTag.remove(); + }; + container.insertBefore(scriptTag, container.children[0]); + } catch (e) { + console.error('Lumen: Injection failed', e); + } + + // Listen for messages from the inpage script + window.addEventListener('message', (event) => { + if (event.data && event.data.source === 'lumen-inpage') { + const message = event.data; + const usePort = [ + 'enable', + 'eth_requestAccounts', + 'eth_sendTransaction', + 'personal_sign', + 'eth_sign', + 'eth_signTypedData', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + 'signDirect', + 'signAmino' + ].includes(message.method); + + const requestPayload = { + type: 'lumen-provider-request', + method: message.method, + params: message.params, + origin: window.location.origin, + requestId: message.id + }; + + const sendViaPort = () => { + return new Promise((resolve, reject) => { + const port = chrome.runtime.connect({ name: 'lumen-provider' }); + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + try { port.disconnect(); } catch {} + reject(new Error('Request timeout')); + }, 60 * 1000); + + const cleanup = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + try { port.disconnect(); } catch {} + }; + + port.onMessage.addListener((response) => { + if (response?.requestId !== message.id) return; + cleanup(); + resolve(response); + }); + + port.onDisconnect.addListener(() => { + if (settled) return; + const err = chrome.runtime.lastError?.message || 'Port disconnected'; + cleanup(); + reject(new Error(err)); + }); + + port.postMessage(requestPayload); + }); + }; + + const send = usePort + ? sendViaPort() + : chrome.runtime.sendMessage(requestPayload); + + Promise.resolve(send) + .then((response: any) => { + window.postMessage({ + source: 'lumen-content-script', + id: message.id, + ...response + }, '*'); + }) + .catch((error: any) => { + window.postMessage({ + source: 'lumen-content-script', + id: message.id, + error: error?.message || 'Unknown error occurred' + }, '*'); + }); + } + }); + + // Pre-warm background service worker + try { + chrome.runtime.sendMessage({ type: 'lumen-ping' }).catch(() => { }); + } catch (e) { + // ignore + } +})(); diff --git a/src/contentScript.js b/src/contentScript.js new file mode 100644 index 0000000..d0ffe29 --- /dev/null +++ b/src/contentScript.js @@ -0,0 +1,235 @@ +/** + * LUMEN WALLET CONTENT SCRIPT - ISOLATED WORLD BRIDGE + * + * This script runs in the "Isolated World" context (Content Script context). + * It acts as a secure bridge between the Main World and the Extension Background. + * + * CONTEXT: Isolated World (Content Script) + * - Has its own separate JavaScript environment + * - Can access Chrome extension APIs (chrome.runtime.sendMessage) + * - Can manipulate the page DOM (to inject scripts) + * - Can use window.postMessage to communicate with Main World + * - Cannot directly access Main World's window object + * + * SECURITY: UUID Handshake + * - Generates a unique UUID for this page session + * - Injects the UUID into the provider script + * - Validates all messages contain the correct UUID + * - Prevents malicious page scripts from forging messages + */ + +(function() { + 'use strict'; + + /** + * Generate a cryptographically secure UUID v4 + * This UUID serves as a shared secret between this Content Script + * and the injected Provider Script + */ + function generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + + const LUMEN_UUID = generateUUID(); + + /** + * STEP 1: Inject the Provider Script into the Main World + * + * Instead of inline script injection (which violates CSP), + * we inject a script tag with src attribute and pass UUID via data attribute + */ + function injectProviderScript() { + try { + // Create script element that loads inject.js + const script = document.createElement('script'); + script.src = chrome.runtime.getURL('inject.js'); + script.setAttribute('data-lumen-uuid', LUMEN_UUID); + script.setAttribute('data-lumen-injected', 'true'); + + // Inject into page at the earliest possible moment + (document.head || document.documentElement).appendChild(script); + + // Clean up - remove the script tag after it loads + script.onload = () => { + script.remove(); + }; + + script.onerror = (error) => { + console.error('[Lumen Content Script] Failed to load inject.js:', error); + }; + + } catch (error) { + console.error('[Lumen Content Script] Injection error:', error); + } + } + + /** + * STEP 2: Listen for messages from Main World (window.lumen) + * + * The injected provider uses window.postMessage to send requests. + * We validate the UUID and forward to the Background service worker. + */ + window.addEventListener('message', async (event) => { + // Only accept messages from this window + if (event.source !== window) return; + + const message = event.data; + + // Check if message is intended for us + if (message.target !== 'lumen-content-script') return; + + // SECURITY: Validate UUID to prevent spoofing + if (message.uuid !== LUMEN_UUID) { + return; + } + + // Only process request type messages + if (message.type !== 'request') return; + + + try { + const usePort = [ + 'enable', + 'eth_requestAccounts', + 'eth_sendTransaction', + 'personal_sign', + 'eth_sign', + 'eth_signTypedData', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + 'signDirect', + 'signAmino' + ].includes(message.method); + + const requestPayload = { + type: 'lumen-provider-request', + method: message.method, + params: message.params, + origin: window.location.origin, + requestId: message.requestId + }; + + const sendViaPort = () => { + return new Promise((resolve, reject) => { + const port = chrome.runtime.connect({ name: 'lumen-provider' }); + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + try { port.disconnect(); } catch {} + reject(new Error('Request timeout')); + }, 60 * 1000); + + const cleanup = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + try { port.disconnect(); } catch {} + }; + + port.onMessage.addListener((response) => { + if (response?.requestId !== message.requestId) return; + cleanup(); + resolve(response); + }); + + port.onDisconnect.addListener(() => { + if (settled) return; + const err = chrome.runtime.lastError?.message || 'Port disconnected'; + cleanup(); + reject(new Error(err)); + }); + + port.postMessage(requestPayload); + }); + }; + + /** + * STEP 3: Forward to Background Service Worker + * + * Use chrome.runtime.sendMessage to send the request to background.js + * This is where the actual wallet logic lives (signing, account management, etc.) + */ + const response = usePort + ? await sendViaPort() + : await chrome.runtime.sendMessage(requestPayload); + + /** + * STEP 4: Send response back to Main World + * + * The background worker responds, and we forward it back to the provider + * via postMessage, including the UUID for validation + */ + window.postMessage({ + target: 'lumen-provider', + uuid: LUMEN_UUID, + type: 'response', + requestId: message.requestId, + data: response.data, + error: response.error + }, '*'); + + + } catch (error) { + // Handle errors and send error response back to provider + console.error('[Lumen Content Script] Error processing request:', error); + + window.postMessage({ + target: 'lumen-provider', + uuid: LUMEN_UUID, + type: 'response', + requestId: message.requestId, + error: error.message || 'Unknown error occurred' + }, '*'); + } + }); + + /** + * STEP 5: Listen for events from Background Service Worker + * + * The background can send events (accountsChanged, chainChanged, etc.) + * that need to be forwarded to the provider + */ + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // Only process messages from the background + if (message.type === 'lumen-provider-event') { + // Forward event to Main World provider + window.postMessage({ + target: 'lumen-provider', + uuid: LUMEN_UUID, + type: 'event', + eventName: message.eventName, + data: message.data + }, '*'); + + sendResponse({ success: true }); + } + }); + + /** + * STEP 6: Handle Origin Verification + * + * Additional security layer - verify the page origin is not blacklisted + * (Optional - can be used to block known malicious sites) + */ + function isOriginAllowed(origin) { + // For now, allow all origins + // In production, you might want to implement a blacklist + return true; + } + + // Initialize: Inject the provider script + injectProviderScript(); + + // Pre-warm background service worker + try { + chrome.runtime.sendMessage({ type: 'lumen-ping' }).catch(() => {}); + } catch (e) { + // ignore + } + +})(); diff --git a/src/hooks/useSendTransaction.ts b/src/hooks/useSendTransaction.ts index fc30e0e..47a0f8d 100644 --- a/src/hooks/useSendTransaction.ts +++ b/src/hooks/useSendTransaction.ts @@ -1,5 +1,6 @@ import { useState } from 'react'; import { buildAndSignSendTx, broadcastTx } from '../modules/sdk/tx'; +import { NetworkManager } from '../modules/sdk/network'; import type { LumenWallet } from '../modules/sdk/key-manager'; interface SendState { @@ -24,6 +25,11 @@ export const useSendTransaction = () => { setState({ isLoading: true, error: null, successHash: null }); try { + // 0. Sync and resolve best endpoint once + const nm = NetworkManager.getInstance(); + await nm.sync(); + const preferredEndpoint = await nm.getRestEndpoint(); + // Give UI time to render the loading state/spinner before heavy blocking crypto await new Promise(r => setTimeout(r, 100)); // 1. Validation @@ -36,17 +42,13 @@ export const useSendTransaction = () => { } // 2. Conversion (LMN -> ulmn) - // Using BigInt/string math to avoid float precision issues is better, but for MVP float * 1m is ok - // strictly speaking, we should use a library like 'decimal.js' or careful string parsing. - // For now: const amountUlmn = Math.round(amount * 1_000_000).toString(); - // 3. Build & Sign (Dual Sign) - // Updated signature matches new tx.ts - const txBytes = await buildAndSignSendTx(fromWallet, toAddress, amountUlmn, memo); + // 3. Build & Sign (Dual Sign) - use the preferred endpoint + const { txBytes, endpoint } = await buildAndSignSendTx(fromWallet, toAddress, amountUlmn, memo, preferredEndpoint); - // 4. Broadcast - const txHash = await broadcastTx(txBytes); + // 4. Broadcast - use the SAME endpoint used for signing + const txHash = await broadcastTx(txBytes, endpoint); setState({ isLoading: false, diff --git a/src/inject.js b/src/inject.js new file mode 100644 index 0000000..27e6732 --- /dev/null +++ b/src/inject.js @@ -0,0 +1,325 @@ +/** + * LUMEN WALLET PROVIDER - MAIN WORLD INJECTION SCRIPT + * + * This script runs in the "Main World" context (the regular webpage context). + * It creates the window.lumen provider object that dApps interact with. + * + * CONTEXT: Main World + * - Shares the same window object as the webpage's JavaScript + * - Cannot access Chrome extension APIs (chrome.runtime, etc.) + * - Can use window.postMessage to communicate with Content Script + * + * SECURITY: UUID Handshake + * - A unique UUID is embedded in this script at injection time + * - All messages include this UUID for verification + * - Prevents malicious page scripts from spoofing responses + */ + +(function() { + 'use strict'; + + // Read UUID from the script tag's data attribute + // Content Script passes UUID via data-lumen-uuid attribute + const scriptTag = document.querySelector('script[data-lumen-injected="true"]'); + const LUMEN_UUID = scriptTag ? scriptTag.getAttribute('data-lumen-uuid') : null; + + if (!LUMEN_UUID) { + console.error('[Lumen] UUID not found, provider injection failed'); + return; + } + + // Prevent double injection + if (window.lumen) { + return; + } + + // Event listener storage for custom events + const eventListeners = new Map(); + + // Request tracking: Maps request IDs to their Promise resolve/reject functions + const pendingRequests = new Map(); + let requestIdCounter = 0; + const REQUEST_TIMEOUT_MS = 60 * 1000; + + const normalizePubKey = (pubKey) => { + if (!pubKey) return new Uint8Array(); + if (pubKey instanceof Uint8Array) return pubKey; + if (Array.isArray(pubKey)) return new Uint8Array(pubKey); + if (typeof pubKey === 'object') { + const values = Object.values(pubKey).filter((v) => typeof v === 'number'); + return new Uint8Array(values); + } + return new Uint8Array(); + }; + + const bytesToBase64 = (value) => { + if (!value) return ''; + if (typeof value === 'string') return value; + let bytes = value; + if (Array.isArray(value)) { + bytes = new Uint8Array(value); + } else if (typeof value === 'object' && !(value instanceof Uint8Array)) { + const values = Object.values(value).filter((v) => typeof v === 'number'); + bytes = new Uint8Array(values); + } + if (!(bytes instanceof Uint8Array)) return ''; + let binary = ''; + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + }; + + const base64ToBytes = (value) => { + if (!value) return new Uint8Array(); + if (value instanceof Uint8Array) return value; + if (Array.isArray(value)) return new Uint8Array(value); + if (typeof value === 'object') { + const values = Object.values(value).filter((v) => typeof v === 'number'); + return new Uint8Array(values); + } + if (typeof value !== 'string') return new Uint8Array(); + try { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } catch { + return new Uint8Array(); + } + }; + + const normalizeSignDoc = (signDoc) => { + if (!signDoc || typeof signDoc !== 'object') return signDoc; + return { + ...signDoc, + accountNumber: signDoc.accountNumber?.toString?.() ?? signDoc.accountNumber, + bodyBytes: bytesToBase64(signDoc.bodyBytes), + authInfoBytes: bytesToBase64(signDoc.authInfoBytes), + }; + }; + + const normalizeSignResponse = (response) => { + if (!response || !response.signed) return response; + return { + ...response, + signed: { + ...response.signed, + bodyBytes: base64ToBytes(response.signed.bodyBytes), + authInfoBytes: base64ToBytes(response.signed.authInfoBytes), + } + }; + }; + + /** + * Listen for responses from Content Script (Isolated World) + * Messages come via window.postMessage with our UUID + */ + window.addEventListener('message', (event) => { + // Only accept messages from same origin + if (event.source !== window) return; + + const message = event.data; + + // Verify message is for Lumen and has valid UUID + if (message.target !== 'lumen-provider') return; + if (message.uuid !== LUMEN_UUID) { + return; + } + + // Handle response for pending request + if (message.type === 'response' && message.requestId !== undefined) { + const pendingRequest = pendingRequests.get(message.requestId); + if (pendingRequest) { + if (message.error) { + pendingRequest.reject(new Error(message.error)); + } else { + pendingRequest.resolve(message.data); + } + pendingRequests.delete(message.requestId); + } + } + + // Handle wallet events (chainChanged, accountsChanged, etc.) + if (message.type === 'event') { + const listeners = eventListeners.get(message.eventName) || []; + listeners.forEach(listener => { + try { + listener(message.data); + } catch (error) { + console.error('[Lumen] Event listener error:', error); + } + }); + } + }); + + /** + * Send request to Content Script via postMessage + * Returns a Promise that resolves when response is received + */ + function sendRequest(method, params = {}) { + return new Promise((resolve, reject) => { + const requestId = requestIdCounter++; + + // Store Promise resolver for this request + pendingRequests.set(requestId, { resolve, reject }); + + // Send message to Content Script (Isolated World) + window.postMessage({ + target: 'lumen-content-script', + uuid: LUMEN_UUID, + type: 'request', + requestId, + method, + params + }, '*'); + + // Timeout after 30 seconds + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + reject(new Error('Request timeout')); + } + }, REQUEST_TIMEOUT_MS); + }); + } + + /** + * EIP-1193 Ethereum Provider API + * Main interface that dApps use to interact with the wallet + */ + const lumenProvider = { + version: '1.0.1', + /** + * EIP-1193 Standard Method + * @param {Object} args - Request arguments with method and params + * @returns {Promise} - Resolves with the result or rejects with error + * + * Example Usage: + * window.lumen.request({ method: 'eth_requestAccounts' }) + * window.lumen.request({ method: 'eth_sendTransaction', params: [txObj] }) + */ + request: async (args) => { + if (!args || typeof args !== 'object') { + throw new Error('Request args must be an object'); + } + if (!args.method || typeof args.method !== 'string') { + throw new Error('Request must include a method string'); + } + + return sendRequest(args.method, args.params); + }, + + /** + * Event listener support (EIP-1193) + * Standard events: connect, disconnect, accountsChanged, chainChanged, message + */ + on: (eventName, callback) => { + if (!eventListeners.has(eventName)) { + eventListeners.set(eventName, []); + } + eventListeners.get(eventName).push(callback); + }, + + removeListener: (eventName, callback) => { + if (eventListeners.has(eventName)) { + const listeners = eventListeners.get(eventName); + const index = listeners.indexOf(callback); + if (index > -1) { + listeners.splice(index, 1); + } + } + }, + + /** + * Provider identification + */ + isLumen: true, + isConnected: () => true, + + /** + * Keplr-style provider methods (Cosmos dApps) + */ + enable: async (chainId) => { + return sendRequest('enable', { chainId }); + }, + + getKey: async (chainId) => { + return sendRequest('getKey', { chainId }); + }, + + getOfflineSigner: (chainId) => { + return { + getAccounts: async () => { + const key = await sendRequest('getKey', { chainId }); + const keyData = key?.data ?? key ?? {}; + return [{ + address: keyData.bech32Address, + algo: keyData.algo, + pubkey: normalizePubKey(keyData.pubKey), + }]; + }, + signDirect: async (signerAddress, signDoc) => { + const response = await sendRequest('signDirect', { signerAddress, signDoc: normalizeSignDoc(signDoc) }); + return normalizeSignResponse(response); + }, + signAmino: async (signerAddress, signDoc) => { + return sendRequest('signAmino', { signerAddress, signDoc }); + } + }; + }, + + getOfflineSignerAuto: async (chainId) => { + return lumenProvider.getOfflineSigner(chainId); + }, + + experimentalSuggestChain: async (chainInfo) => { + return sendRequest('experimentalSuggestChain', { chainInfo }); + }, + + /** + * Legacy support methods (some dApps may use these) + */ + + send: (methodOrPayload, paramsOrCallback) => { + // Support both legacy send formats + if (typeof methodOrPayload === 'string') { + return lumenProvider.request({ + method: methodOrPayload, + params: paramsOrCallback + }); + } + // Legacy sendAsync format + if (typeof paramsOrCallback === 'function') { + lumenProvider.request(methodOrPayload) + .then(result => paramsOrCallback(null, { result })) + .catch(error => paramsOrCallback(error, null)); + } + } + }; + + // Inject the provider into window + Object.defineProperty(window, 'lumen', { + value: lumenProvider, + writable: false, + configurable: false, + enumerable: true + }); + + // Also expose as window.ethereum for maximum compatibility (if needed) + // Uncomment if you want dApps to use Lumen as the default provider + // if (!window.ethereum) { + // Object.defineProperty(window, 'ethereum', { + // value: lumenProvider, + // writable: false, + // configurable: false + // }); + // } + + // Dispatch initialization event + window.dispatchEvent(new Event('lumen#initialized')); + window.dispatchEvent(new Event('lumen_keystone_ready')); + +})(); diff --git a/src/inpage.ts b/src/inpage.ts new file mode 100644 index 0000000..966853f --- /dev/null +++ b/src/inpage.ts @@ -0,0 +1,150 @@ + +// This script is injected into the web page to define window.lumen +(function () { + const pendingRequests = new Map(); + + const normalizePubKey = (pubKey: any): Uint8Array => { + if (!pubKey) return new Uint8Array(); + if (pubKey instanceof Uint8Array) return pubKey; + if (Array.isArray(pubKey)) return new Uint8Array(pubKey); + if (typeof pubKey === 'object') { + const values = Object.values(pubKey).filter(v => typeof v === 'number'); + return new Uint8Array(values as number[]); + } + return new Uint8Array(); + }; + + const bytesToBase64 = (value: any): string => { + if (!value) return ''; + if (typeof value === 'string') return value; + let bytes = value; + if (Array.isArray(value)) { + bytes = new Uint8Array(value); + } else if (typeof value === 'object' && !(value instanceof Uint8Array)) { + const values = Object.values(value).filter(v => typeof v === 'number'); + bytes = new Uint8Array(values as number[]); + } + if (!(bytes instanceof Uint8Array)) return ''; + let binary = ''; + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + }; + + const base64ToBytes = (value: any): Uint8Array => { + if (!value) return new Uint8Array(); + if (value instanceof Uint8Array) return value; + if (Array.isArray(value)) return new Uint8Array(value); + if (typeof value === 'object') { + const values = Object.values(value).filter(v => typeof v === 'number'); + return new Uint8Array(values as number[]); + } + if (typeof value !== 'string') return new Uint8Array(); + try { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } catch { + return new Uint8Array(); + } + }; + + const normalizeSignDoc = (signDoc: any) => { + if (!signDoc || typeof signDoc !== 'object') return signDoc; + return { + ...signDoc, + accountNumber: signDoc.accountNumber?.toString?.() ?? signDoc.accountNumber, + bodyBytes: bytesToBase64(signDoc.bodyBytes), + authInfoBytes: bytesToBase64(signDoc.authInfoBytes), + }; + }; + + const normalizeSignResponse = (response: any) => { + if (!response || !response.signed) return response; + return { + ...response, + signed: { + ...response.signed, + bodyBytes: base64ToBytes(response.signed.bodyBytes), + authInfoBytes: base64ToBytes(response.signed.authInfoBytes), + } + }; + }; + + function sendRequest(method: string, params: any): Promise { + const id = Math.random().toString(36).substring(7); + return new Promise((resolve, reject) => { + pendingRequests.set(id, { resolve, reject }); + window.postMessage({ + source: 'lumen-inpage', + id, + method, + params + }, '*'); + }); + } + + window.addEventListener('message', (event) => { + if (event.data && event.data.source === 'lumen-content-script' && event.data.id) { + const request = pendingRequests.get(event.data.id); + if (request) { + pendingRequests.delete(event.data.id); + if (event.data.error) { + request.reject(new Error(event.data.error)); + } else { + request.resolve(event.data.result); + } + } + } + }); + + const lumen = { + version: '1.0.1', + enable: async (chainId: string) => { + return await sendRequest('enable', { chainId }); + }, + getKey: async (chainId: string) => { + return await sendRequest('getKey', { chainId }); + }, + experimentalSuggestChain: async (chainInfo: any) => { + return await sendRequest('experimentalSuggestChain', { chainInfo }); + }, + getOfflineSigner: (chainId: string) => { + return { + getAccounts: async () => { + const key = await sendRequest('getKey', { chainId }) as any; + const keyData = key?.data ?? key ?? {}; + return [{ + address: keyData.bech32Address, + algo: keyData.algo, + pubkey: normalizePubKey(keyData.pubKey), + }]; + }, + signDirect: async (signerAddress: string, signDoc: any) => { + const response = await sendRequest('signDirect', { signerAddress, signDoc: normalizeSignDoc(signDoc) }); + return normalizeSignResponse(response); + }, + signAmino: async (signerAddress: string, signDoc: any) => { + return await sendRequest('signAmino', { signerAddress, signDoc }); + } + }; + }, + getOfflineSignerAuto: async (chainId: string) => { + return lumen.getOfflineSigner(chainId); + } + }; + + // Define window.lumen + Object.defineProperty(window, 'lumen', { + value: lumen, + writable: false, + configurable: false + }); + + // Dispatch event so dApps can detect it + window.dispatchEvent(new Event('lumen_keystone_ready')); +})(); diff --git a/src/mocks/fs.ts b/src/mocks/fs.ts index efe855c..87bde2b 100644 --- a/src/mocks/fs.ts +++ b/src/mocks/fs.ts @@ -8,7 +8,6 @@ export const promises = { ? chrome.runtime.getURL('dilithium3.wasm') : '/dilithium3.wasm'; - console.log(`[MockFS] Intercepting read for ${path}, fetching from ${url}`); const response = await fetch(url); if (!response.ok) throw new Error(`HTTP error ${response.status}`); diff --git a/src/modules/history/history.ts b/src/modules/history/history.ts index 9144000..d995b74 100644 --- a/src/modules/history/history.ts +++ b/src/modules/history/history.ts @@ -13,8 +13,8 @@ const STORAGE_KEY_HISTORY = 'lumen_history_v1'; const HISTORY_LIMIT = 100; -const RPC_BASE = "https://rpc-lumen.winnode.xyz"; -const API_BASE = "https://api-lumen.winnode.xyz"; +const RPC_BASE = "https://rpc.cosmos.directory/lumen"; +const API_BASE = "https://rest.cosmos.directory/lumen"; export interface Transaction { hash: string; @@ -112,7 +112,6 @@ export class HistoryManager { */ static async syncGap(address: string) { try { - console.log(`[GapSync] Starting fast sync for ${address}...`); // Query: transfer.recipient = 'address' // We request per_page=50, page=1, order=desc (newest first) @@ -125,11 +124,9 @@ export class HistoryManager { const txs = pData.result?.txs || []; if (txs.length === 0) { - console.log("[GapSync] No recent history found via RPC search."); return; } - console.log(`[GapSync] Found ${txs.length} transactions via RPC Search.`); // Dynamic Import for Crypto Libs (Optimization) const { fromBase64, toHex } = await import('@cosmjs/encoding'); @@ -219,7 +216,6 @@ export class HistoryManager { * Forces a Deep Rescan (last 100 blocks) to capture the credit immediately. */ static async onBalanceIncrease(address: string) { - console.log(`[HistoryManager] Balance increased! Forcing deep rescan...`); await this.syncBlocks(address, 100, true); // Force=true ignores watermark } @@ -244,7 +240,9 @@ export class HistoryManager { const latestData = await latestRes.json(); latestHeight = parseInt(latestData.block.header.height); } - } catch (e) { console.warn("API Height fetch failed", e); } + } catch (e) { + /* API failed */ + } if (latestHeight === 0) { // Fallback to RPC for Height @@ -271,7 +269,6 @@ export class HistoryManager { if (!force && startHeight >= latestHeight) return; const endHeight = Math.min(startHeight + depth, latestHeight); - console.log(`[HybridScanner] Syncing ${startHeight + 1} to ${endHeight} (Force: ${force}, Head: ${latestHeight})`); // 3. Scan Loop const heights = []; @@ -328,7 +325,6 @@ export class HistoryManager { // STRATEGY B: REST Raw Block (Fallback) if (!successRPC) { try { - console.log(`[HybridScanner] Falling back to Raw REST for block ${height}`); const blockRes = await fetch(`${API_BASE}/cosmos/base/tendermint/v1beta1/blocks/${height}`); if (blockRes.ok) { const blockJson = await blockRes.json(); @@ -431,7 +427,6 @@ export class HistoryManager { } private static async saveFoundTx(address: string, height: string, time: string, source: string, events: any[], txBytes?: string, index?: number) { - console.log(`[HybridScanner] Match found: ${source} at ${height}`); // 1. Compute Hash let hash = ""; @@ -441,7 +436,7 @@ export class HistoryManager { const { sha256 } = await import('@cosmjs/crypto'); const hashBytes = sha256(fromBase64(txBytes)); hash = toHex(hashBytes).toUpperCase(); - } catch (e) { console.warn("Hash calc failed", e); } + } catch { } } // Fallback: Use Synthetic ID for System Events or missing bytes diff --git a/src/modules/sdk/governance.ts b/src/modules/sdk/governance.ts index f095146..f5a3ad4 100644 --- a/src/modules/sdk/governance.ts +++ b/src/modules/sdk/governance.ts @@ -9,6 +9,8 @@ import { Any } from 'cosmjs-types/google/protobuf/any'; import * as LumenSDK from '@lumen-chain/sdk'; import type { LumenWallet } from './key-manager'; +import { NetworkManager } from './network'; + const CHAIN_ID = "lumen"; const GAS_LIMIT = BigInt(200000); @@ -51,8 +53,9 @@ const ensureUint8Array = (input: string | Uint8Array | undefined): Uint8Array => }; /* Helper: Account Info */ -async function fetchAccountInfo(address: string, apiEndpoint: string) { - const res = await fetch(`${apiEndpoint}/cosmos/auth/v1beta1/accounts/${address}`); +async function fetchAccountInfo(address: string, apiEndpoint?: string) { + const endpoint = apiEndpoint || await NetworkManager.getInstance().getRestEndpoint(); + const res = await fetch(`${endpoint}/cosmos/auth/v1beta1/accounts/${address}`); if (!res.ok) { throw new Error(`Account fetch failed: ${res.status} ${res.statusText}`); } @@ -79,7 +82,7 @@ export async function voteOnProposal( walletData: LumenWallet, proposalId: string, voteOption: 'yes' | 'no' | 'abstain' | 'veto', - apiEndpoint: string + apiEndpoint?: string ): Promise { /* 1. Prepare ECDSA Wallet */ @@ -91,6 +94,9 @@ export async function voteOnProposal( throw new Error(`Mnemonic derived address ${account.address} does not match wallet address ${walletData.address}`); } + /* Sync RPCs */ + await NetworkManager.getInstance().sync(); + const { accountNumber, sequence } = await fetchAccountInfo(walletData.address, apiEndpoint); /* 2. Prepare PQC Keys */ @@ -205,7 +211,8 @@ export async function voteOnProposal( } /* Broadcaster */ -async function broadcastTx(txBytes: Uint8Array, restUrl: string): Promise { +async function broadcastTx(txBytes: Uint8Array, restUrl?: string): Promise { + const endpoint = restUrl || await NetworkManager.getInstance().getRestEndpoint(); const txBytesBase64 = Buffer.from(txBytes).toString('base64'); const body = { @@ -213,7 +220,7 @@ async function broadcastTx(txBytes: Uint8Array, restUrl: string): Promise p.provider === this.manualProvider); + const rest = REST_PROVIDERS.find(p => p.provider === this.manualProvider); + if (rpc) this.currentRpc = rpc.address; + if (rest) this.currentRest = rest.address; + } + } + } + } + + public async saveSettings() { + if (typeof chrome !== 'undefined' && chrome.storage) { + await chrome.storage.local.set({ + rpc_settings: { + isAuto: this.isAuto, + manualProvider: this.manualProvider + } + }); + } + } + + public async getRpcEndpoint(): Promise { + if (this.isAuto) { + await this.refreshIfNecessary(); + } + // If we have an RPC for the same provider as current REST, use it, otherwise fallback + const currentProvider = REST_PROVIDERS.find(p => p.address === this.currentRest)?.provider; + const matchingRpc = RPC_PROVIDERS.find(p => p.provider === currentProvider); + return matchingRpc ? matchingRpc.address : this.currentRpc; + } + + public async getRestEndpoint(forceSync: boolean = false): Promise { + if (this.isAuto) { + await this.refreshIfNecessary(forceSync); + } + return this.currentRest; + } + + /** + * Returns the primary REST endpoint immediately for high-speed UI lookups (e.g. Balance). + * Bypasses the consensus refresh wait. + */ + public getQuickRestEndpoint(): string { + return REST_PROVIDERS[0].address; + } + + public async sync() { + if (!this.isAuto) return; + await this.refreshBestRpc(true); + } + + public setAuto(auto: boolean) { + this.isAuto = auto; + if (auto) { + this.refreshBestRpc(true); + } else if (this.manualProvider) { + const rest = REST_PROVIDERS.find(p => p.provider === this.manualProvider); + if (rest) this.currentRest = rest.address; + } + this.saveSettings(); + } + + public setManualProvider(provider: string) { + this.isAuto = false; + this.manualProvider = provider; + const rest = REST_PROVIDERS.find(p => p.provider === provider); + if (rest) this.currentRest = rest.address; + this.saveSettings(); + } + + public isAutoMode(): boolean { + return this.isAuto; + } + + public getSelectedProvider(): string | null { + if (this.isAuto) return "Auto"; + return this.manualProvider; + } + + private async refreshIfNecessary(force: boolean = false) { + const now = Date.now(); + if (force || now - this.lastUpdate > this.UPDATE_INTERVAL) { + await this.refreshBestRpc(force); + } + } + + public async refreshBestRpc(force: boolean = false) { + if (!this.isAuto && !force) return; + + + const results = await Promise.allSettled( + REST_PROVIDERS.map(async (p) => { + const start = Date.now(); + // 1. Fetch Latest Block + const blockRes = await fetch(`${p.address.replace(/\/$/, '')}/cosmos/base/tendermint/v1beta1/blocks/latest`, { + signal: AbortSignal.timeout(3000) + }); + if (!blockRes.ok) throw new Error('Block fetch failed'); + const blockData = await blockRes.json(); + const height = parseInt(blockData.block?.header?.height || "0"); + const chainId = blockData.block?.header?.chain_id; + + // 2. Fetch Syncing Status + const syncRes = await fetch(`${p.address.replace(/\/$/, '')}/cosmos/base/tendermint/v1beta1/syncing`, { + signal: AbortSignal.timeout(2000) + }); + let isSyncing = false; + if (syncRes.ok) { + const syncData = await syncRes.json(); + isSyncing = syncData.syncing === true; + } + + return { + provider: p.provider, + address: p.address, + height, + latency: Date.now() - start, + isSyncing, + chainId + }; + }) + ); + + const fulfilled = results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map(r => r.value) + .filter(v => { + const normalized = String(v.chainId || '').toLowerCase(); + const chainOk = normalized === CHAIN_ID || normalized.startsWith(`${CHAIN_ID}-`); + return chainOk && v.height > 0 && !v.isSyncing; + }); + + if (fulfilled.length === 0) { + return; + } + + const sorted = fulfilled.sort((a, b) => b.height - a.height || a.latency - b.latency); + + if (fulfilled.length >= 3 || (force && fulfilled.length > 0)) { + const best = sorted[0]; + this.currentRest = best.address; + this.lastUpdate = Date.now(); + } else if (force) { + const best = sorted[0]; + this.currentRest = best.address; + this.lastUpdate = Date.now(); + } + } +} diff --git a/src/modules/sdk/staking.ts b/src/modules/sdk/staking.ts index 64a7230..4ba7ba7 100644 --- a/src/modules/sdk/staking.ts +++ b/src/modules/sdk/staking.ts @@ -9,9 +9,11 @@ import { Any } from 'cosmjs-types/google/protobuf/any'; import * as LumenSDK from '@lumen-chain/sdk'; import type { LumenWallet } from './key-manager'; +import { NetworkManager } from './network'; + const CHAIN_ID = "lumen"; const GAS_LIMIT = BigInt(300000); -const API_ENDPOINT = 'https://api-lumen.winnode.xyz'; +// API_ENDPOINT replaced by NetworkManager /* Helper: Hex/Base64 Decoder */ const ensureUint8Array = (input: string | Uint8Array | undefined): Uint8Array => { @@ -46,7 +48,8 @@ const ensureUint8Array = (input: string | Uint8Array | undefined): Uint8Array => /* Helper: Account Info */ async function fetchAccountInfo(address: string) { - const res = await fetch(`${API_ENDPOINT}/cosmos/auth/v1beta1/accounts/${address}`); + const endpoint = await NetworkManager.getInstance().getRestEndpoint(); + const res = await fetch(`${endpoint}/cosmos/auth/v1beta1/accounts/${address}`); if (!res.ok) { throw new Error(`Account fetch failed: ${res.status} ${res.statusText}`); } @@ -61,7 +64,8 @@ async function fetchAccountInfo(address: string) { /* Fetch Delegations */ export async function fetchDelegations(delegatorAddress: string) { try { - const res = await fetch(`${API_ENDPOINT}/cosmos/staking/v1beta1/delegations/${delegatorAddress}`); + const endpoint = await NetworkManager.getInstance().getRestEndpoint(); + const res = await fetch(`${endpoint}/cosmos/staking/v1beta1/delegations/${delegatorAddress}`); if (!res.ok) { if (res.status === 404) return []; throw new Error(`Failed to fetch delegations: ${res.status}`); @@ -77,7 +81,8 @@ export async function fetchDelegations(delegatorAddress: string) { /* Fetch Unbonding Delegations */ export async function fetchUnbondingDelegations(delegatorAddress: string) { try { - const res = await fetch(`${API_ENDPOINT}/cosmos/staking/v1beta1/delegators/${delegatorAddress}/unbonding_delegations`); + const endpoint = await NetworkManager.getInstance().getRestEndpoint(); + const res = await fetch(`${endpoint}/cosmos/staking/v1beta1/delegators/${delegatorAddress}/unbonding_delegations`); if (!res.ok) { if (res.status === 404) return []; throw new Error(`Failed to fetch unbonding delegations: ${res.status}`); @@ -93,7 +98,8 @@ export async function fetchUnbondingDelegations(delegatorAddress: string) { /* Fetch Rewards */ export async function fetchRewards(delegatorAddress: string) { try { - const res = await fetch(`${API_ENDPOINT}/cosmos/distribution/v1beta1/delegators/${delegatorAddress}/rewards`); + const endpoint = await NetworkManager.getInstance().getRestEndpoint(); + const res = await fetch(`${endpoint}/cosmos/distribution/v1beta1/delegators/${delegatorAddress}/rewards`); if (!res.ok) { if (res.status === 404) return { total: [], rewards: [] }; throw new Error(`Failed to fetch rewards: ${res.status}`); @@ -112,7 +118,8 @@ export async function fetchRewards(delegatorAddress: string) { /* Fetch Validators */ export async function fetchValidators() { try { - const res = await fetch(`${API_ENDPOINT}/cosmos/staking/v1beta1/validators?status=BOND_STATUS_BONDED`); + const endpoint = await NetworkManager.getInstance().getRestEndpoint(); + const res = await fetch(`${endpoint}/cosmos/staking/v1beta1/validators?status=BOND_STATUS_BONDED`); if (!res.ok) { throw new Error(`Failed to fetch validators: ${res.status}`); } @@ -127,7 +134,8 @@ export async function fetchValidators() { /* Fetch Validator Info */ export async function fetchValidator(validatorAddress: string) { try { - const res = await fetch(`${API_ENDPOINT}/cosmos/staking/v1beta1/validators/${validatorAddress}`); + const endpoint = await NetworkManager.getInstance().getRestEndpoint(); + const res = await fetch(`${endpoint}/cosmos/staking/v1beta1/validators/${validatorAddress}`); if (!res.ok) { throw new Error(`Failed to fetch validator: ${res.status}`); } @@ -152,6 +160,9 @@ export async function delegateTokens( throw new Error(`Address mismatch`); } + /* Sync RPCs */ + await NetworkManager.getInstance().sync(); + const { accountNumber, sequence } = await fetchAccountInfo(walletData.address); /* Prepare PQC Keys */ @@ -270,6 +281,9 @@ export async function undelegateTokens( throw new Error(`Address mismatch`); } + /* Sync RPCs */ + await NetworkManager.getInstance().sync(); + const { accountNumber, sequence } = await fetchAccountInfo(walletData.address); /* Prepare PQC Keys */ @@ -387,6 +401,9 @@ export async function claimRewards( throw new Error(`Address mismatch`); } + /* Sync RPCs */ + await NetworkManager.getInstance().sync(); + const { accountNumber, sequence } = await fetchAccountInfo(walletData.address); /* Prepare PQC Keys */ @@ -500,7 +517,8 @@ async function broadcastTx(txBytes: Uint8Array): Promise { mode: 'BROADCAST_MODE_SYNC' }; - const res = await fetch(`${API_ENDPOINT}/cosmos/tx/v1beta1/txs`, { + const endpoint = await NetworkManager.getInstance().getRestEndpoint(); + const res = await fetch(`${endpoint}/cosmos/tx/v1beta1/txs`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) diff --git a/src/modules/sdk/tx.ts b/src/modules/sdk/tx.ts index ad1b293..74b14d7 100644 --- a/src/modules/sdk/tx.ts +++ b/src/modules/sdk/tx.ts @@ -10,10 +10,13 @@ import * as LumenSDK from '@lumen-chain/sdk'; // Import types import type { LumenWallet } from './key-manager'; +/* Config */ +import { NetworkManager } from './network'; + /* Config */ const CHAIN_ID = "lumen"; const GAS_LIMIT = BigInt(200000); -const DEFAULT_REST = "https://api-lumen.winnode.xyz"; +// DEFAULT_REST replaced by NetworkManager /* Helper: Hex/Base64 Decoder */ const ensureUint8Array = (input: string | Uint8Array | undefined): Uint8Array => { @@ -54,8 +57,9 @@ const ensureUint8Array = (input: string | Uint8Array | undefined): Uint8Array => }; /* Helper: Account Info */ -async function fetchAccountInfo(address: string, apiEndpoint: string = DEFAULT_REST) { - const res = await fetch(`${apiEndpoint}/cosmos/auth/v1beta1/accounts/${address}`); +async function fetchAccountInfo(address: string, apiEndpoint?: string) { + const endpoint = apiEndpoint || await NetworkManager.getInstance().getRestEndpoint(); + const res = await fetch(`${endpoint}/cosmos/auth/v1beta1/accounts/${address}`); if (!res.ok) { throw new Error(`Account fetch failed: ${res.status} ${res.statusText}`); } @@ -73,8 +77,8 @@ export async function buildAndSignSendTx( toAddress: string, amountUlmn: string, memo: string, - apiEndpoint: string = DEFAULT_REST -): Promise { + apiEndpoint?: string +): Promise<{ txBytes: Uint8Array; endpoint: string }> { /* 1. Prepare ECDSA Wallet */ const wallet = await DirectSecp256k1HdWallet.fromMnemonic(walletData.mnemonic, { prefix: 'lmn' }); @@ -85,7 +89,9 @@ export async function buildAndSignSendTx( throw new Error(`Mnemonic derived address ${account.address} does not match wallet address ${walletData.address}`); } - const { accountNumber, sequence } = await fetchAccountInfo(walletData.address, apiEndpoint); + /* Sync RPCs before getting account info */ + const endpointUsed = apiEndpoint || await NetworkManager.getInstance().getRestEndpoint(); + const { accountNumber, sequence } = await fetchAccountInfo(walletData.address, endpointUsed); /* 2. Prepare Keys (Decoded) */ /* Support both new 'pqcKey' and legacy 'pqc' structures. */ @@ -224,11 +230,15 @@ export async function buildAndSignSendTx( signatures: [Buffer.from(finalSig.signature, 'base64')] }); - return TxRaw.encode(txRaw).finish(); + return { + txBytes: TxRaw.encode(txRaw).finish(), + endpoint: endpointUsed + }; } /* 3. Broadcaster */ -export async function broadcastTx(txBytes: Uint8Array, restUrl: string = DEFAULT_REST): Promise { +export async function broadcastTx(txBytes: Uint8Array, restUrl?: string): Promise { + const endpoint = restUrl || await NetworkManager.getInstance().getRestEndpoint(); const txBytesBase64 = Buffer.from(txBytes).toString('base64'); const body = { @@ -236,7 +246,7 @@ export async function broadcastTx(txBytes: Uint8Array, restUrl: string = DEFAULT mode: 'BROADCAST_MODE_SYNC' }; - const res = await fetch(`${restUrl}/cosmos/tx/v1beta1/txs`, { + const res = await fetch(`${endpoint}/cosmos/tx/v1beta1/txs`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) diff --git a/src/modules/swap/orchestrator.ts b/src/modules/swap/orchestrator.ts index db3b57f..8ead955 100644 --- a/src/modules/swap/orchestrator.ts +++ b/src/modules/swap/orchestrator.ts @@ -48,14 +48,14 @@ export const SwapOrchestrator = { const memo = quote.deposit.memo; // Sign - const txBytes = await buildAndSignSendTx( + const { txBytes, endpoint } = await buildAndSignSendTx( wallet, quote.deposit.address, amountUlmn, memo ); - // Broadcast - return await broadcastTx(txBytes); + // Broadcast using the same endpoint + return await broadcastTx(txBytes, endpoint); } }; diff --git a/src/modules/vault/vault.ts b/src/modules/vault/vault.ts index 5aa1815..4cd5e70 100644 --- a/src/modules/vault/vault.ts +++ b/src/modules/vault/vault.ts @@ -5,6 +5,9 @@ import type { LumenWallet } from '../sdk/key-manager'; const STORAGE_KEY_VAULT = 'lumen_vault_v1'; const STORAGE_KEY_SESSION = 'lumen_session_v1'; const STORAGE_KEY_SETTINGS = 'lumen_settings_v1'; +const IDB_NAME = 'lumen_wallet_keys'; +const IDB_STORE = 'session_keys'; +const IDB_VAULT_KEY = 'vault_key'; const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; /* 5 Minutes */ const PBKDF2_ITERATIONS = 310_000; @@ -43,6 +46,9 @@ const hasChromeStorageLocal = () => const hasChromeStorageSession = () => typeof chrome !== 'undefined' && !!chrome.storage?.session; +const hasIndexedDb = () => + typeof indexedDB !== 'undefined'; + const storageLocal = { get: async (key: string): Promise => { if (hasChromeStorageLocal()) { @@ -69,6 +75,68 @@ const storageLocal = { } }; +const openKeyDb = (): Promise => { + return new Promise((resolve, reject) => { + const request = indexedDB.open(IDB_NAME, 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(IDB_STORE)) { + db.createObjectStore(IDB_STORE); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +}; + +const idbPutKey = async (key: CryptoKey): Promise => { + const db = await openKeyDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(IDB_STORE, 'readwrite'); + tx.objectStore(IDB_STORE).put(key, IDB_VAULT_KEY); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }); +}; + +const idbGetKey = async (): Promise => { + const db = await openKeyDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(IDB_STORE, 'readonly'); + const req = tx.objectStore(IDB_STORE).get(IDB_VAULT_KEY); + req.onsuccess = () => { + db.close(); + resolve((req.result as CryptoKey) || null); + }; + req.onerror = () => { + db.close(); + reject(req.error); + }; + }); +}; + +const idbDeleteKey = async (): Promise => { + const db = await openKeyDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(IDB_STORE, 'readwrite'); + tx.objectStore(IDB_STORE).delete(IDB_VAULT_KEY); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }); +}; + const storageSession = { get: async (key: string): Promise => { if (hasChromeStorageSession()) { @@ -137,14 +205,40 @@ const deriveAesKeyFromPassword = async (password: string, salt: Uint8Array, iter }, baseKey, { name: 'AES-GCM', length: 256 }, - false, + false, // Non-extractable for production safety ['encrypt', 'decrypt'] ); }; let cachedVaultKey: CryptoKey | null = null; +const STORAGE_KEY_SESSION_KEY = 'lumen_session_key_jwk'; export class VaultManager { + /** + * Persist the key to session storage (memory only, safe for SW) + */ + private static async persistSessionKey(key: CryptoKey) { + if (!hasIndexedDb()) return; + try { + await idbPutKey(key); + } catch (e) { + console.error('Failed to persist session key:', e); + } + } + + /** + * Restore key from session storage + */ + private static async restoreSessionKey(): Promise { + if (!hasIndexedDb()) return null; + try { + return await idbGetKey(); + } catch (e) { + console.error('Failed to restore session key:', e); + return null; + } + } + /** * Encrypts and stores the wallet data in the vault. * Also refreshes the session. @@ -153,7 +247,9 @@ export class VaultManager { */ static async lock(wallets: LumenWallet[], password?: string): Promise { if (!cachedVaultKey && !password) { - throw new Error('Wallet is locked.'); + // Try to restore first + cachedVaultKey = await this.restoreSessionKey(); + if (!cachedVaultKey) throw new Error('Wallet is locked.'); } const cryptoImpl = getCrypto(); @@ -177,6 +273,7 @@ export class VaultManager { iterations = PBKDF2_ITERATIONS; key = await deriveAesKeyFromPassword(password!, salt, iterations); cachedVaultKey = key; + await this.persistSessionKey(key); } const plaintext = new TextEncoder().encode(JSON.stringify(wallets)); @@ -240,6 +337,7 @@ export class VaultManager { const wallets = Array.isArray(parsed) ? parsed as LumenWallet[] : [parsed as LumenWallet]; cachedVaultKey = key; + await this.persistSessionKey(key); await this.touchSession(); return wallets; } catch (e) { @@ -251,6 +349,11 @@ export class VaultManager { * Returns the decrypted wallets using the in-memory session key. */ static async getWallets(): Promise { + if (!cachedVaultKey) { + // Try to restore + cachedVaultKey = await this.restoreSessionKey(); + } + if (!cachedVaultKey) { throw new Error('Session expired.'); } @@ -345,6 +448,14 @@ export class VaultManager { static async clearSession() { cachedVaultKey = null; await storageSession.remove(STORAGE_KEY_SESSION); + await storageSession.remove(STORAGE_KEY_SESSION_KEY); + if (hasIndexedDb()) { + try { + await idbDeleteKey(); + } catch (e) { + console.error('Failed to clear session key:', e); + } + } } /** diff --git a/src/permissions.ts b/src/permissions.ts new file mode 100644 index 0000000..cbd80ce --- /dev/null +++ b/src/permissions.ts @@ -0,0 +1,22 @@ +export const REQUIRED_HOST_PERMISSIONS = [ + 'https://rpc.cosmos.directory/lumen/*', + 'https://rest.cosmos.directory/lumen/*', + 'https://rpc.lumen.chaintools.tech/*', + 'https://lumen.blocksync.me/*', + 'https://lumen-mainnet-rpc.mekonglabs.com/*', + 'https://rpc-lumen.onenov.xyz/*', + 'https://lumen-api.node9x.com/*', + 'https://api.lumen.chaintools.tech/*', + 'https://lumen-mainnet-api.mekonglabs.com/*', + 'https://lumen-api.linknode.org/*', + 'https://api-lumen.winnode.xyz/*' +]; + +export const originToPattern = (origin: string): string | null => { + try { + const url = new URL(origin); + return `${url.origin}/*`; + } catch { + return null; + } +}; diff --git a/vite.config.ts b/vite.config.ts index 727e8a8..73c75e5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -42,6 +42,8 @@ export default defineConfig({ input: { main: path.resolve(__dirname, 'index.html'), background: path.resolve(__dirname, 'src/background.ts'), + contentScript: path.resolve(__dirname, 'src/contentScript.js'), + inject: path.resolve(__dirname, 'src/inject.js'), }, output: { entryFileNames: '[name].js',