From 69220d5742b9ef413d590e73b15ef8df2f015cec Mon Sep 17 00:00:00 2001 From: Riki J Iskandar Date: Tue, 3 Feb 2026 23:29:14 +0700 Subject: [PATCH 1/2] feat: implement cosmos transaction signing and dapp integration - Added transaction signing support in background script (cosmos_signDirect, cosmos_signAmino) - Updated ApprovalModal to display transaction details and confirmation UI - Integrated VaultManager for secure signing flow - Added DAPP_INTEGRATION.md with technical documentation and custom wallet adapter guide - Cleaned up test files and fixed build dependencies --- DAPP_INTEGRATION.md | 273 ++++++++++++++++ background.js | 218 +++++++++++++ manifest.json | 15 +- package.json | 2 +- scripts/copy-manifest.js | 20 ++ src/App.tsx | 45 ++- src/background.ts | 531 +++++++++++++++++++++++++++++++ src/components/ApprovalModal.tsx | 310 ++++++++++++++++++ src/components/ApprovalPage.tsx | 233 ++++++++++++++ src/components/Settings.tsx | 81 +++++ src/content-script.ts | 31 ++ src/contentScript.js | 182 +++++++++++ src/inject.js | 216 +++++++++++++ src/inpage.ts | 73 +++++ vite.config.ts | 2 + 15 files changed, 2226 insertions(+), 6 deletions(-) create mode 100644 DAPP_INTEGRATION.md create mode 100644 scripts/copy-manifest.js create mode 100644 src/components/ApprovalModal.tsx create mode 100644 src/components/ApprovalPage.tsx create mode 100644 src/content-script.ts create mode 100644 src/contentScript.js create mode 100644 src/inject.js create mode 100644 src/inpage.ts diff --git a/DAPP_INTEGRATION.md b/DAPP_INTEGRATION.md new file mode 100644 index 0000000..1678d1c --- /dev/null +++ b/DAPP_INTEGRATION.md @@ -0,0 +1,273 @@ +# Lumen Wallet Integration Guide + +This document provides technical details on how to integrate Decentralized Applications (dApps) with the Lumen Wallet Chrome Extension. + +## 1. Provider Detection + +Lumen Wallet injects a global API into the `window` object of every visited website. dApps should check for the existence of `window.lumen`. + +```javascript +if (window.lumen) { + console.log('Lumen Wallet is installed!'); +} else { + console.log('Lumen Wallet not found. Please install securely.'); +} +``` + +### Type Definition (TypeScript) +For TypeScript projects, you can extend the Window interface: + +```typescript +interface LumenProvider { + request: (args: { method: string; params?: any }) => Promise; + on: (eventName: string, callback: (data: any) => void) => void; + removeListener: (eventName: string, callback: (data: any) => void) => void; + isLumen: boolean; + enable: () => Promise; +} + +declare global { + interface Window { + lumen?: LumenProvider; + } +} +``` + +## 2. Connecting to the Wallet + +To request access to the user's wallet (and prompt them to unlock/approve), use the `eth_requestAccounts` method. This follows the EIP-1193 standard. + +**Method:** `eth_requestAccounts` + +```javascript +try { + const accounts = await window.lumen.request({ + method: 'eth_requestAccounts' + }); + + const userAddress = accounts[0]; + console.log('Connected:', userAddress); +} catch (error) { + if (error.code === 4001) { + // User rejected request + console.error('User denied connection'); + } else { + console.error('Connection failed', error); + } +} +``` + +*Note: You can also use the legacy `window.lumen.enable()` method, which behaves identically.* + +## 3. Getting Current Account & Session + +Once connected, you can check the currently active account without prompting a popup (provided the wallet is unlocked and the site is already approved). + +**Method:** `eth_accounts` + +```javascript +const accounts = await window.lumen.request({ + method: 'eth_accounts' +}); + +if (accounts.length > 0) { + console.log('Active session for:', accounts[0]); +} else { + console.log('No active session. Please call eth_requestAccounts first.'); +} +``` + +### Session Maintenance +Lumen Wallet handles session management automatically: +* **Auto-Lock**: The wallet locks itself after a period of inactivity (default 5 mins), clearing the session from memory. +* **Re-Connection**: If the wallet is locked, calls to `eth_requestAccounts` will prompt the user to unlock the wallet first. If the site was previously approved, no new approval dialog is shown—it simply unlocks and returns the address. +* **Persisted Permissions**: Site approvals are stored permanently until manually disconnected by the user in Settings. + +## 4. Signing Transactions (Cosmos) + +Lumen Wallet supports standard Cosmos signing flow. + +### Direct Signing (Protobuf) +Used for standard Cosmos SDK transactions. + +**Method:** `cosmos_signDirect` + +```javascript +/* Standard Cosmos SignDoc */ +const signDoc = { + bodyBytes: "...", // Uint8Array or Hex + authInfoBytes: "...", + chainId: "lumen-1", + accountNumber: "1", +}; + +try { + const result = await window.lumen.request({ + method: 'cosmos_signDirect', + params: { + signerAddress: userAddress, + signDoc: signDoc + } + }); + + // result contains the signed transaction object ready for broadcast + console.log('Signed Tx:', result); +} catch (err) { + console.error('Signing rejected', err); +} +``` + +### Amino Signing (Legacy/Ledger) +Used for older chains or hardware wallet compatibility. + +**Method:** `cosmos_signAmino` + +```javascript +const result = await window.lumen.request({ + method: 'cosmos_signAmino', + params: { + signerAddress: userAddress, + signDoc: aminoSignDoc + } +}); +``` + +## 5. Handling Events + +Lumen Wallet supports the EIP-1193 event system to notify dApps of state changes. + +```javascript +// Listen for account changes (e.g., user switches wallet or disconnects) +window.lumen.on('accountsChanged', (accounts) => { + if (accounts.length === 0) { + console.log('User disconnected'); + } else { + console.log('Account changed to:', accounts[0]); + } +}); + +// Listen for chain/network changes +window.lumen.on('chainChanged', (chainId) => { + console.log('Network switched to:', chainId); + window.location.reload(); // Recommended practice +}); +``` + +## Summary of RPC Methods + +| Method | Description | Params | Returns | +|--------|-------------|--------|---------| +| `eth_requestAccounts` | Connect & Get Address | None | `Promise` | +| `eth_accounts` | Get Address (if connected) | None | `Promise` | +| `eth_chainId` | Get current Chain ID | None | `Promise` | +| `cosmos_signDirect` | Sign Protobuf Tx | `{ signerAddress, signDoc }` | `Promise` | +| `cosmos_signAmino` | Sign Amino Tx | `{ signerAddress, signDoc }` | `Promise` | + +## 6. Custom Wallet Registration (Cosmos Kit / Custom Modals) + +Since Lumen Network is not yet in the `chain-registry` and the wallet is not standard, you must manually register both the **Chain** and the **Wallet** in your dApp. + +### A. Defining the Chain Locally +You must provide the Chain Information object to your wallet provider. + +```typescript +export const lumenChainInfo = { + chain_id: 'lumen', + chain_name: 'lumen', + pretty_name: 'Lumen Network', + status: 'live', + network_type: 'mainnet', + bech32_prefix: 'lmn', + daemon_name: 'lumend', + node_home: '$HOME/.lumen', + key_algos: ['secp256k1'], + slip44: 118, + fees: { + fee_tokens: [{ + denom: 'ulmn', + fixed_min_gas_price: 0.0025, + low_gas_price: 0.0025, + average_gas_price: 0.025, + high_gas_price: 0.04 + }] + }, + apis: { + rpc: [{ address: 'https://rpc.lumen.network', provider: 'Lumen' }], + rest: [{ address: 'https://api.lumen.network', provider: 'Lumen' }] + } +}; +``` + +### B. Creating a Custom Wallet Adapter +Most dApps use a "Cosmos Kit" or similar adapter. Since `window.lumen` does not expose `getOfflineSigner` directly on the window object (unlike Keplr), you need to wrap the `window.lumen.request` calls. + +**Example: Creating a Lumen Wallet Object for Cosmos Kit** + +```typescript +import { MainWalletBase, Wallet } from '@cosmos-kit/core'; + +export const lumenWalletInfo: Wallet = { + name: 'lumen-wallet', + prettyName: 'Lumen Wallet', + mode: 'extension', + mobileDisabled: true, + rejectStyle: { + source: 'request', + borderColor: '#f5d996' + }, + connectEventNamesOnWindow: ['lumen#initialized'], + downloads: [ + { device: 'desktop', browser: 'chrome', link: 'https://chrome.google.com/webstore/...' } + ] +}; + +export class LumenExtensionWallet extends MainWalletBase { + constructor(walletInfo: Wallet) { + super(walletInfo, window.lumen); + } + + async getAccount(chainId: string) { + const accounts = await window.lumen.request({ method: 'eth_requestAccounts' }); + return { + address: accounts[0], + algo: 'secp256k1', + pubkey: new Uint8Array() // Pubkey retrieval via 'cosmos_getKey' if needed + }; + } + + async getOfflineSigner(chainId: string) { + // Return an object that matches OfflineSigner interface + return { + getAccounts: async () => { + const accts = await this.getAccount(chainId); + return [accts]; + }, + signDirect: async (signerAddress, signDoc) => { + return window.lumen.request({ + method: 'cosmos_signDirect', + params: { signerAddress, signDoc } + }); + }, + signAmino: async (signerAddress, signDoc) => { + return window.lumen.request({ + method: 'cosmos_signAmino', + params: { signerAddress, signDoc } + }); + } + }; + } +} +``` + +### C. Integrating into the Modal +Pass your custom wallet and chain to the provider: + +```typescript + + + +``` + 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/manifest.json b/manifest.json index 9859a03..381f24e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, - "name": "Lumen Wallet", + "name": "Lumen Wallet Beta", "version": "1.0.1", - "description": "Non-custodial Lumen Chain wallet with Swap v1 support.", + "description": "[BETA] Non-custodial Lumen Chain wallet with Swap v1 support.", "action": { "default_popup": "index.html", "default_title": "Lumen Wallet" @@ -40,11 +40,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..43b23c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { Governance } from './components/governance/Governance'; import { VaultManager } from './modules/vault/vault'; 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,6 +28,7 @@ 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); @@ -63,6 +65,24 @@ function App() { /* Initial Load & Session Check */ useEffect(() => { const checkSession = async () => { + // Check for pending approval - but only show modal AFTER wallet is unlocked + try { + const result = await chrome.storage.local.get('pendingApprovalRequest'); + if (result.pendingApprovalRequest) { + console.log('[App] Found pending approval request'); + // Check if wallet is unlocked before showing modal + const expired = await VaultManager.isSessionExpired(); + if (!expired) { + console.log('[App] Wallet unlocked, showing approval modal'); + setShowApprovalModal(true); + } else { + console.log('[App] Wallet locked, will show modal after unlock'); + } + } + } catch (e) { + console.log('[App] Error checking pending approval:', e); + } + const exists = await VaultManager.hasWallet(); setHasVault(exists); if (exists) { @@ -80,6 +100,7 @@ function App() { setActiveWalletIndex(foundIdx); } + // No pending approval, proceed to dashboard if (location.pathname === '/' || location.pathname === '/onboarding') { navigate('/dashboard'); } @@ -122,7 +143,9 @@ function App() { } }, 5000); - return () => clearInterval(interval); + return () => { + clearInterval(interval); + }; }, []); const handleUnlock = async (password: string) => { @@ -140,7 +163,16 @@ function App() { setIsLocked(false); setUnlockError(null); - navigate('/dashboard'); + + // Check for pending approval request AFTER unlock + const result = await chrome.storage.local.get('pendingApprovalRequest'); + if (result.pendingApprovalRequest) { + console.log('[App] Found pending approval after unlock, showing modal'); + setShowApprovalModal(true); + navigate('/dashboard'); // Go to dashboard with modal overlay + } else { + navigate('/dashboard'); + } } catch (e: any) { setUnlockError(e?.message || "Incorrect password."); } @@ -383,6 +415,15 @@ function App() { )} + + {/* Approval Modal Overlay */} + {showApprovalModal && ( + { + setShowApprovalModal(false); + }} + /> + )} ); } diff --git a/src/background.ts b/src/background.ts index 9c5618d..85333c6 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,3 +1,10 @@ +// Import VaultManager for wallet operations +import { VaultManager } from './modules/vault/vault'; +import { DirectSecp256k1HdWallet } from '@cosmjs/proto-signing'; +import { Secp256k1HdWallet } from '@cosmjs/amino'; + + + // Set panel behavior if (typeof chrome !== 'undefined' && chrome.sidePanel && chrome.sidePanel.setPanelBehavior) { chrome.sidePanel @@ -29,3 +36,527 @@ 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 from content scripts AND UI responses +if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) { + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // Handle provider requests from content scripts + if (message.type === 'lumen-provider-request') { + console.log('[Lumen Background] Received request:', message); + + handleProviderRequest(message, sender) + .then(response => sendResponse(response)) + .catch(error => { + console.error('[Lumen Background] Error:', error); + sendResponse({ error: error.message || 'Request failed' }); + }); + + return true; // Async response + } + + // Handle user approval/rejection from side panel UI + if (message.type === 'user-response') { + handleUserResponse(message); + sendResponse({ success: true }); + return false; + } + + return false; + }); +} + +/** + * 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 { + 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: 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'; +}>(); + +function generateRequestId(): string { + return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +async function handleEnable(origin: string, _tab: any) { + console.log('[Lumen] Enable request from:', origin); + + 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) { + 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 + console.log('[Lumen] Need user action - locked:', isLocked, 'connected:', isConnected); + const requestId = generateRequestId(); + + // Store pending request + const requestData = { + requestId, + origin, + permissions: ['View wallet address', 'Request transaction signatures'], + type: isLocked ? 'pending-unlock-request' : 'approval-request', + timestamp: Date.now() + }; + + await chrome.storage.local.set({ + pendingApprovalRequest: requestData + }); + + // Show badge to notify user + if (chrome.action && chrome.action.setBadgeText) { + await chrome.action.setBadgeText({ text: '1' }); + await chrome.action.setBadgeBackgroundColor({ color: '#f5d996ff' }); + await chrome.action.setTitle({ + title: `${origin} wants to connect to your wallet` + }); + } + + console.log('[Lumen] Badge shown, waiting for user to open extension'); + + 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); + // Clear badge and storage + chrome.storage.local.remove('pendingApprovalRequest'); + chrome.action?.setBadgeText({ text: '' }); + reject(new Error('Request timeout')); + } + }, 5 * 60 * 1000); + }); + + } 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) { + console.warn('[Lumen] No pending request:', requestId); + return; + } + + pendRequest.delete(requestId); + + // Clear badge and storage + chrome.storage.local.remove('pendingApprovalRequest'); + chrome.action?.setBadgeText({ text: '' }); + chrome.action?.setTitle({ title: 'Lumen Wallet' }); + + if (approved) { + addConnectedOrigin(pending.origin) + .then(() => getWalletAddress()) + .then((address) => { + if (!address) { + pending.reject(new Error('No wallet found')); + } else { + pending.resolve({ data: [address] }); + } + }) + .catch((error) => pending.reject(error)); + } else { + pending.reject(new Error('User rejected the request')); + } +} + +/** + * 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 getWalletAddress(): Promise { + try { + // Get wallets from vault (requires active session) + const wallets = await VaultManager.getWallets(); + + if (!wallets || wallets.length === 0) { + return null; + } + + // Return first wallet's address + // TODO: Handle multi-wallet selection + 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(['connectedOrigins']) 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({ connectedOrigins: origins }); + console.log('[Lumen] Added connected origin:', origin); + } +} + +// 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 + console.log('[Lumen] Get accounts - origin not connected:', origin); + return { + data: [] + }; + } + + // Connected, return wallet address + const address = await getWalletAddress(); + + console.log('[Lumen] Get accounts request from:', origin); + return { + data: address ? [address] : [] + }; +} + +async function handleGetChainId() { + // Return Lumen chain ID + console.log('[Lumen] Get chain ID request'); + return { + data: 'lumen' // Lumen chain ID + }; +} + +async function handleSendTransaction(params: any, origin: string, _tab: any) { + console.log('[Lumen] Send transaction request:', params); + + const requestId = generateRequestId(); + + // Store pending request + const requestData = { + requestId, + origin, + type: 'transaction-request', + params, + timestamp: Date.now() + }; + + await chrome.storage.local.set({ pendingApprovalRequest: requestData }); + + // Show badge + if (chrome.action) { + chrome.action.setBadgeText({ text: '1' }); + chrome.action.setBadgeBackgroundColor({ color: '#f5d996ff' }); + } + + // 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); + chrome.storage.local.remove('pendingApprovalRequest'); + chrome.action?.setBadgeText({ text: '' }); + reject(new Error('Request timeout')); + } + }, 5 * 60 * 1000); + }); + + // Handle Generic/EVM Transaction Signing (Placeholder) + console.log('[Lumen] Transaction approved by user. Generic/EVM signing not fully implemented.'); + return { error: 'Signing implementation pending for generic transactions' }; +} + +async function handleSignMessage(params: any, _origin: string, _tab: any) { + // 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: any, _origin: string, _tab: any) { + // 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: any, _origin: string, _tab: any) { + // 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: any, _origin: string, _tab: any) { + // 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: string) { + // 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: any, origin: string, _tab: any) { + console.log('[Lumen] Cosmos signAmino request:', params); + + const requestId = generateRequestId(); + + const requestData = { + requestId, + origin, + type: 'transaction-request', + params: { ...params, mode: 'amino' }, + timestamp: Date.now() + }; + + await chrome.storage.local.set({ pendingApprovalRequest: requestData }); + + if (chrome.action) { + chrome.action.setBadgeText({ text: '1' }); + chrome.action.setBadgeBackgroundColor({ color: '#f5d996ff' }); + } + + // 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); + chrome.storage.local.remove('pendingApprovalRequest'); + chrome.action?.setBadgeText({ text: '' }); + reject(new Error('Request timeout')); + } + }, 5 * 60 * 1000); + }); + + // User approved, sign logic + try { + const wallets = await VaultManager.getWallets(); + const signerAddress = params.signerAddress; + + // 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, params.signDoc); + + return { data: result }; + } catch (error: any) { + console.error('SignAmino failed:', error); + throw new Error(error.message || 'Signing failed'); + } +} + +async function handleCosmosSignDirect(params: any, origin: string, _tab: any) { + console.log('[Lumen] Cosmos signDirect request:', params); + + const requestId = generateRequestId(); + + const requestData = { + requestId, + origin, + type: 'transaction-request', + params: { ...params, mode: 'direct' }, + timestamp: Date.now() + }; + + await chrome.storage.local.set({ pendingApprovalRequest: requestData }); + + if (chrome.action) { + chrome.action.setBadgeText({ text: '1' }); + chrome.action.setBadgeBackgroundColor({ color: '#f5d996ff' }); + } + + // 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); + chrome.storage.local.remove('pendingApprovalRequest'); + chrome.action?.setBadgeText({ text: '' }); + reject(new Error('Request timeout')); + } + }, 5 * 60 * 1000); + }); + + // User approved, sign logic + try { + const wallets = await VaultManager.getWallets(); + const signerAddress = params.signerAddress; + + // 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' }); + + // Sign + const result = await wallet.signDirect(signerAddress, params.signDoc); + + return { data: result }; + } 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 + console.log('[Lumen] Would emit event:', eventName, data); +} +*/ diff --git a/src/components/ApprovalModal.tsx b/src/components/ApprovalModal.tsx new file mode 100644 index 0000000..2667af7 --- /dev/null +++ b/src/components/ApprovalModal.tsx @@ -0,0 +1,310 @@ +import { useState, useEffect } from 'react'; +import { VaultManager } from '../modules/vault/vault'; + +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 [pendingRequest, setPendingRequest] = useState(null); + const [walletAddress, setWalletAddress] = useState(''); + const [loading, setLoading] = useState(true); + const [isLocked, setIsLocked] = useState(false); + + useEffect(() => { + checkPendingRequest(); + }, []); + + const checkPendingRequest = async () => { + try { + const result = await chrome.storage.local.get('pendingApprovalRequest'); + if (result.pendingApprovalRequest) { + console.log('[ApprovalModal] Found pending request:', result.pendingApprovalRequest); + setPendingRequest(result.pendingApprovalRequest as ApprovalRequest); + await loadWalletInfo(); + } else { + console.log('[ApprovalModal] No pending request found'); + 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 activeWallet = wallets[0]; + setWalletAddress(activeWallet.address); + } + } catch (error) { + console.error('[ApprovalModal] Failed to load wallet:', error); + } finally { + setLoading(false); + } + }; + + const handleApprove = () => { + if (!pendingRequest) return; + + chrome.runtime.sendMessage({ + type: 'user-response', + requestId: pendingRequest.requestId, + approved: true + }); + + onClose(); + }; + + const handleReject = () => { + if (!pendingRequest) return; + + chrome.runtime.sendMessage({ + type: 'user-response', + requestId: pendingRequest.requestId, + approved: false + }); + + onClose(); + }; + + const copyAddress = () => { + navigator.clipboard.writeText(walletAddress); + }; + + // 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} +

+
+
+
+ + {/* Content */} +
+ {!isTransaction ? ( + <> +
+ This site is requesting your permission to: +
+ + {/* Permissions List */} +
+

Permissions

+
    + {pendingRequest?.permissions.map((permission, index) => ( +
  • + + + + {permission} +
  • + ))} +
+
+ + ) : ( +
+ {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..76d54b3 --- /dev/null +++ b/src/components/ApprovalPage.tsx @@ -0,0 +1,233 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { VaultManager } from '../modules/vault/vault'; + +interface ApprovalRequest { + requestId: string; + origin: string; + permissions: string[]; + type: 'approval-request' | 'pending-unlock-request'; +} + +export function ApprovalPage() { + const navigate = useNavigate(); + const [pendingRequest, setPendingRequest] = useState(null); + const [walletAddress, setWalletAddress] = useState(''); + const [balance, setBalance] = useState('0.00'); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Check for pending approval request in storage + const checkPendingRequest = async () => { + try { + const result = await chrome.storage.local.get('pendingApprovalRequest'); + if (result.pendingApprovalRequest) { + console.log('[ApprovalPage] Found pending request:', result.pendingApprovalRequest); + setPendingRequest(result.pendingApprovalRequest as ApprovalRequest); + } else { + console.log('[ApprovalPage] No pending request found'); + } + } 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 + console.log('[ApprovalPage] Wallet is locked, waiting for unlock'); + setLoading(false); + return; + } + + // Wallet is unlocked, safe to load + const wallets = await VaultManager.getWallets(); + if (wallets && wallets.length > 0) { + const activeWallet = 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 handleApprove = () => { + if (!pendingRequest) 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 */} +
+ {/* 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..613eecd 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -9,12 +9,22 @@ 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([]); useEffect(() => { const load = async () => { const current = await VaultManager.getLockSettings(); setType(current.type); setValue(current.value); + + // Load connected dApps + try { + const result = await chrome.storage.local.get(['connectedOrigins']); + const origins = (result.connectedOrigins as string[]) || []; + setConnectedDApps(origins); + } catch (e) { + console.log('Error loading connected dApps:', e); + } }; load(); }, []); @@ -25,6 +35,27 @@ export const Settings: React.FC = ({ onBack }) => { 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 +116,56 @@ 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} +
+
+
+ +
+ ))} +
+ )} +
+
+
+
+ + )} + {!isLocked && pendingCount > 0 && ( +
+
+
+
Pending requests
+
{pendingCount} waiting for your approval.
+
+
+
+ )} 0)) && ( { setShowApprovalModal(false); diff --git a/src/background.ts b/src/background.ts index 85333c6..40ec01b 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,7 +1,120 @@ // Import VaultManager for wallet operations import { VaultManager } from './modules/vault/vault'; -import { DirectSecp256k1HdWallet } from '@cosmjs/proto-signing'; -import { Secp256k1HdWallet } from '@cosmjs/amino'; +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 }; +}; @@ -37,6 +150,20 @@ 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 * @@ -53,9 +180,12 @@ if (typeof chrome !== 'undefined' && chrome.contextMenus && chrome.contextMenus. // 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') { - console.log('[Lumen Background] Received request:', message); handleProviderRequest(message, sender) .then(response => sendResponse(response)) @@ -64,20 +194,83 @@ if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) sendResponse({ error: error.message || 'Request failed' }); }); - return true; // Async response + return true; } - // Handle user approval/rejection from side panel UI + /* 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 @@ -88,6 +281,25 @@ 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': @@ -129,7 +341,7 @@ async function handleProviderRequest(message: any, sender: any): Promise { // Cosmos-specific methods (for Lumen chain) case 'cosmos_getKey': case 'getKey': - return await handleCosmosGetKey(params?.chainId); + return await handleCosmosGetKey(params?.chainId, origin); case 'cosmos_signAmino': case 'signAmino': @@ -139,6 +351,9 @@ async function handleProviderRequest(message: any, sender: any): Promise { case 'signDirect': return await handleCosmosSignDirect(params, origin, sender.tab); + case 'experimentalSuggestChain': + return { data: null }; + default: throw new Error(`Unsupported method: ${method}`); } @@ -164,12 +379,216 @@ const pendRequest = new Map(); +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) { - console.log('[Lumen] Enable request from:', origin); try { // Check state @@ -179,6 +598,7 @@ async function handleEnable(origin: string, _tab: any) { // If UNLOCKED and CONNECTED, return address immediately if (!isLocked && isConnected) { + await ensureHostPermissions(); const address = await getWalletAddress(); if (!address) { throw new Error('No wallet found'); @@ -188,11 +608,11 @@ async function handleEnable(origin: string, _tab: any) { // Need user interaction (unlock OR approval OR both) // Show badge notification instead of opening popup - console.log('[Lumen] Need user action - locked:', isLocked, 'connected:', isConnected); + await prunePendingQueue(); const requestId = generateRequestId(); // Store pending request - const requestData = { + const requestData: PendingRequestData = { requestId, origin, permissions: ['View wallet address', 'Request transaction signatures'], @@ -200,20 +620,9 @@ async function handleEnable(origin: string, _tab: any) { timestamp: Date.now() }; - await chrome.storage.local.set({ - pendingApprovalRequest: requestData - }); - - // Show badge to notify user - if (chrome.action && chrome.action.setBadgeText) { - await chrome.action.setBadgeText({ text: '1' }); - await chrome.action.setBadgeBackgroundColor({ color: '#f5d996ff' }); - await chrome.action.setTitle({ - title: `${origin} wants to connect to your wallet` - }); - } + await enqueuePendingRequest(requestData); - console.log('[Lumen] Badge shown, waiting for user to open extension'); + await openApprovalPopup(_tab); return new Promise((resolve, reject) => { pendRequest.set(requestId, { resolve, reject, origin, type: 'enable' }); @@ -222,12 +631,11 @@ async function handleEnable(origin: string, _tab: any) { setTimeout(() => { if (pendRequest.has(requestId)) { pendRequest.delete(requestId); - // Clear badge and storage - chrome.storage.local.remove('pendingApprovalRequest'); - chrome.action?.setBadgeText({ text: '' }); + removePendingRequest(requestId).catch(() => { + }); reject(new Error('Request timeout')); } - }, 5 * 60 * 1000); + }, REQUEST_TIMEOUT_MS); }); } catch (error: any) { @@ -241,33 +649,50 @@ function handleUserResponse(message: any) { const pending = pendRequest.get(requestId); if (!pending) { - console.warn('[Lumen] No pending request:', requestId); + removePendingRequest(requestId).catch(() => { + }); return; } pendRequest.delete(requestId); - // Clear badge and storage - chrome.storage.local.remove('pendingApprovalRequest'); - chrome.action?.setBadgeText({ text: '' }); - chrome.action?.setTitle({ title: 'Lumen Wallet' }); + removePendingRequest(requestId).catch(() => { + }); if (approved) { - addConnectedOrigin(pending.origin) - .then(() => getWalletAddress()) - .then((address) => { + (async () => { + try { + if (pending.type === 'enable') { + await ensureSitePermission(pending.origin); + await ensureHostPermissions(); + } + await addConnectedOrigin(pending.origin); + const address = await getWalletAddress(); if (!address) { - pending.reject(new Error('No wallet found')); - } else { - pending.resolve({ data: [address] }); + throw new Error('No wallet found'); } - }) - .catch((error) => pending.reject(error)); + 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 */ @@ -289,6 +714,18 @@ async function checkWalletLocked(): Promise { } } +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) @@ -298,8 +735,13 @@ async function getWalletAddress(): Promise { return null; } - // Return first wallet's address - // TODO: Handle multi-wallet selection + 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) @@ -311,7 +753,7 @@ async function getWalletAddress(): Promise { } async function getConnectedOrigins(): Promise { - const result = await chrome.storage.local.get(['connectedOrigins']) as { connectedOrigins?: string[] }; + const result = await chrome.storage.local.get([STORAGE_CONNECTED_ORIGINS]) as { connectedOrigins?: string[] }; return result.connectedOrigins || []; } @@ -319,8 +761,15 @@ async function addConnectedOrigin(origin: string): Promise { const origins = await getConnectedOrigins(); if (!origins.includes(origin)) { origins.push(origin); - await chrome.storage.local.set({ connectedOrigins: origins }); - console.log('[Lumen] Added connected origin:', 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 }); } } @@ -333,7 +782,6 @@ async function handleGetAccounts(origin: string) { if (!connectedOrigins.includes(origin)) { // Not connected, return empty array - console.log('[Lumen] Get accounts - origin not connected:', origin); return { data: [] }; @@ -342,7 +790,6 @@ async function handleGetAccounts(origin: string) { // Connected, return wallet address const address = await getWalletAddress(); - console.log('[Lumen] Get accounts request from:', origin); return { data: address ? [address] : [] }; @@ -350,33 +797,31 @@ async function handleGetAccounts(origin: string) { async function handleGetChainId() { // Return Lumen chain ID - console.log('[Lumen] Get chain ID request'); return { data: 'lumen' // Lumen chain ID }; } async function handleSendTransaction(params: any, origin: string, _tab: any) { - console.log('[Lumen] Send transaction request:', params); + await prunePendingQueue(); const requestId = generateRequestId(); // Store pending request - const requestData = { + const requestData: PendingRequestData = { requestId, origin, + permissions: ['Request transaction signatures'], type: 'transaction-request', params, timestamp: Date.now() }; - await chrome.storage.local.set({ pendingApprovalRequest: requestData }); + await enqueuePendingRequest(requestData); + chrome.runtime.sendMessage({ type: 'show-approval' }).catch(() => { + }); - // Show badge - if (chrome.action) { - chrome.action.setBadgeText({ text: '1' }); - chrome.action.setBadgeBackgroundColor({ color: '#f5d996ff' }); - } + await openApprovalPopup(_tab); // Wait for user approval await new Promise((resolve, reject) => { @@ -385,80 +830,126 @@ async function handleSendTransaction(params: any, origin: string, _tab: any) { setTimeout(() => { if (pendRequest.has(requestId)) { pendRequest.delete(requestId); - chrome.storage.local.remove('pendingApprovalRequest'); - chrome.action?.setBadgeText({ text: '' }); + removePendingRequest(requestId).catch(() => { + }); reject(new Error('Request timeout')); } - }, 5 * 60 * 1000); + }, REQUEST_TIMEOUT_MS); }); // Handle Generic/EVM Transaction Signing (Placeholder) - console.log('[Lumen] Transaction approved by user. Generic/EVM signing not fully implemented.'); 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 - console.log('[Lumen] Sign message request:', params); 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 - console.log('[Lumen] Sign typed data request:', params); 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 - console.log('[Lumen] Add chain request:', params); 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 - 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: string) { - // 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 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) { - console.log('[Lumen] Cosmos signAmino request:', params); + await prunePendingQueue(); const requestId = generateRequestId(); - const requestData = { + const requestData: PendingRequestData = { requestId, origin, + permissions: ['Request transaction signatures'], type: 'transaction-request', params: { ...params, mode: 'amino' }, timestamp: Date.now() }; - await chrome.storage.local.set({ pendingApprovalRequest: requestData }); + await enqueuePendingRequest(requestData); + chrome.runtime.sendMessage({ type: 'show-approval' }).catch(() => { + }); - if (chrome.action) { - chrome.action.setBadgeText({ text: '1' }); - chrome.action.setBadgeBackgroundColor({ color: '#f5d996ff' }); - } + await openApprovalPopup(_tab); // Wait for user approval await new Promise((resolve, reject) => { @@ -467,26 +958,119 @@ async function handleCosmosSignAmino(params: any, origin: string, _tab: any) { setTimeout(() => { if (pendRequest.has(requestId)) { pendRequest.delete(requestId); - chrome.storage.local.remove('pendingApprovalRequest'); - chrome.action?.setBadgeText({ text: '' }); + removePendingRequest(requestId).catch(() => { + }); reject(new Error('Request timeout')); } - }, 5 * 60 * 1000); + }, REQUEST_TIMEOUT_MS); }); // User approved, sign logic try { const wallets = await VaultManager.getWallets(); - const signerAddress = params.signerAddress; + 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, params.signDoc); + 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'); - return { data: result }; + 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'); @@ -494,24 +1078,22 @@ async function handleCosmosSignAmino(params: any, origin: string, _tab: any) { } async function handleCosmosSignDirect(params: any, origin: string, _tab: any) { - console.log('[Lumen] Cosmos signDirect request:', params); + await prunePendingQueue(); const requestId = generateRequestId(); - const requestData = { + const requestData: PendingRequestData = { requestId, origin, + permissions: ['Request transaction signatures'], type: 'transaction-request', params: { ...params, mode: 'direct' }, timestamp: Date.now() }; - await chrome.storage.local.set({ pendingApprovalRequest: requestData }); + await enqueuePendingRequest(requestData); - if (chrome.action) { - chrome.action.setBadgeText({ text: '1' }); - chrome.action.setBadgeBackgroundColor({ color: '#f5d996ff' }); - } + await openApprovalPopup(_tab); // Wait for user approval await new Promise((resolve, reject) => { @@ -520,17 +1102,22 @@ async function handleCosmosSignDirect(params: any, origin: string, _tab: any) { setTimeout(() => { if (pendRequest.has(requestId)) { pendRequest.delete(requestId); - chrome.storage.local.remove('pendingApprovalRequest'); - chrome.action?.setBadgeText({ text: '' }); + removePendingRequest(requestId).catch(() => { + }); reject(new Error('Request timeout')); } - }, 5 * 60 * 1000); + }, REQUEST_TIMEOUT_MS); }); // User approved, sign logic try { const wallets = await VaultManager.getWallets(); - const signerAddress = params.signerAddress; + 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); @@ -539,10 +1126,56 @@ async function handleCosmosSignDirect(params: any, origin: string, _tab: any) { // 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, params.signDoc); + 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: result }; + return { data: normalizedResult }; } catch (error: any) { console.error('Signing failed:', error); throw new Error(error.message || 'Signing failed'); @@ -557,6 +1190,5 @@ async function handleCosmosSignDirect(params: any, origin: string, _tab: any) { async function emitProviderEvent(eventName: string, data: any) { // 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/src/components/ApprovalModal.tsx b/src/components/ApprovalModal.tsx index 2667af7..633ed22 100644 --- a/src/components/ApprovalModal.tsx +++ b/src/components/ApprovalModal.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { VaultManager } from '../modules/vault/vault'; +import { originToPattern } from '../permissions'; interface ApprovalRequest { requestId: string; @@ -14,24 +15,50 @@ interface ApprovalModalProps { } export function ApprovalModal({ onClose }: ApprovalModalProps) { - const [pendingRequest, setPendingRequest] = useState(null); + 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(() => { - checkPendingRequest(); + loadPendingQueue(); }, []); - const checkPendingRequest = async () => { + 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('pendingApprovalRequest'); - if (result.pendingApprovalRequest) { - console.log('[ApprovalModal] Found pending request:', result.pendingApprovalRequest); - setPendingRequest(result.pendingApprovalRequest as ApprovalRequest); + 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 { - console.log('[ApprovalModal] No pending request found'); setLoading(false); } } catch (error) { @@ -57,7 +84,9 @@ export function ApprovalModal({ onClose }: ApprovalModalProps) { const wallets = await VaultManager.getWallets(); if (wallets && wallets.length > 0) { - const activeWallet = wallets[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) { @@ -67,16 +96,43 @@ export function ApprovalModal({ onClose }: ApprovalModalProps) { } }; - const handleApprove = () => { + 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 }); - onClose(); + 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 = () => { @@ -88,18 +144,39 @@ export function ApprovalModal({ onClose }: ApprovalModalProps) { approved: false }); - onClose(); + 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; - } + if (!loading && !pendingRequest) { + onClose(); + return null; + } // Extract domain from origin let displayOrigin = pendingRequest?.origin || ''; @@ -194,8 +271,36 @@ export function ApprovalModal({ onClose }: ApprovalModalProps) { {displayOrigin}

+
+ + {totalCount > 0 ? `${currentIndex + 1}/${totalCount}` : '0/0'} + +
+ + +
+
+ {permissionError && ( +
+
+ {permissionError} +
+
+ )} {/* Content */}
@@ -209,7 +314,7 @@ export function ApprovalModal({ onClose }: ApprovalModalProps) {

Permissions

    - {pendingRequest?.permissions.map((permission, index) => ( + {(pendingRequest?.permissions ?? []).map((permission, index) => (
  • @@ -219,6 +324,11 @@ export function ApprovalModal({ onClose }: ApprovalModalProps) { ))}
+ +
+
Network access required
+ Chrome may ask to allow network access to Lumen RPC/REST endpoints. If denied, the connection will fail. +
) : (
diff --git a/src/components/ApprovalPage.tsx b/src/components/ApprovalPage.tsx index 76d54b3..10fd256 100644 --- a/src/components/ApprovalPage.tsx +++ b/src/components/ApprovalPage.tsx @@ -1,6 +1,7 @@ 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; @@ -11,21 +12,22 @@ interface ApprovalRequest { export function ApprovalPage() { const navigate = useNavigate(); - const [pendingRequest, setPendingRequest] = useState(null); + 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('pendingApprovalRequest'); - if (result.pendingApprovalRequest) { - console.log('[ApprovalPage] Found pending request:', result.pendingApprovalRequest); - setPendingRequest(result.pendingApprovalRequest as ApprovalRequest); + 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 { - console.log('[ApprovalPage] No pending request found'); } } catch (error) { console.error('[ApprovalPage] Error checking pending request:', error); @@ -50,7 +52,6 @@ export function ApprovalPage() { const expired = await VaultManager.isSessionExpired(); if (expired) { // Wallet is locked, don't try to load wallet info - console.log('[ApprovalPage] Wallet is locked, waiting for unlock'); setLoading(false); return; } @@ -58,7 +59,9 @@ export function ApprovalPage() { // Wallet is unlocked, safe to load const wallets = await VaultManager.getWallets(); if (wallets && wallets.length > 0) { - const activeWallet = wallets[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 @@ -72,9 +75,26 @@ export function ApprovalPage() { } }; - const handleApprove = () => { + 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, @@ -158,11 +178,16 @@ export function ApprovalPage() { {/* Content */}
+ {permissionError && ( +
+ {permissionError} +
+ )} {/* Permissions */}

This site will be able to:

    - {pendingRequest.permissions.map((permission, index) => ( + {(pendingRequest.permissions ?? []).map((permission, index) => (
  • diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 613eecd..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; @@ -10,12 +11,21 @@ export const Settings: React.FC = ({ onBack }) => { 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 { @@ -23,14 +33,51 @@ export const Settings: React.FC = ({ onBack }) => { const origins = (result.connectedOrigins as string[]) || []; setConnectedDApps(origins); } catch (e) { - console.log('Error loading connected dApps:', 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); }; @@ -166,6 +213,60 @@ export const Settings: React.FC = ({ onBack }) => {
+
+
+ +

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 index dca9db8..94c2df0 100644 --- a/src/content-script.ts +++ b/src/content-script.ts @@ -17,15 +17,89 @@ // Listen for messages from the inpage script window.addEventListener('message', (event) => { if (event.data && event.data.source === 'lumen-inpage') { - // Forward to background script - chrome.runtime.sendMessage(event.data, (response) => { - // Forward back to inpage script - window.postMessage({ - source: 'lumen-content-script', - id: event.data.id, - ...response - }, '*'); - }); + 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 index 21a3a27..d0ffe29 100644 --- a/src/contentScript.js +++ b/src/contentScript.js @@ -35,7 +35,6 @@ } const LUMEN_UUID = generateUUID(); - console.log('[Lumen Content Script] UUID generated:', LUMEN_UUID); /** * STEP 1: Inject the Provider Script into the Main World @@ -57,7 +56,6 @@ // Clean up - remove the script tag after it loads script.onload = () => { script.remove(); - console.log('[Lumen Content Script] Provider script injected into Main World'); }; script.onerror = (error) => { @@ -86,29 +84,79 @@ // SECURITY: Validate UUID to prevent spoofing if (message.uuid !== LUMEN_UUID) { - console.warn('[Lumen Content Script] Invalid UUID, rejecting message:', message); return; } // Only process request type messages if (message.type !== 'request') return; - console.log('[Lumen Content Script] Received request from Main World:', message); 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 = await chrome.runtime.sendMessage({ - type: 'lumen-provider-request', - method: message.method, - params: message.params, - origin: window.location.origin, - requestId: message.requestId - }); + const response = usePort + ? await sendViaPort() + : await chrome.runtime.sendMessage(requestPayload); /** * STEP 4: Send response back to Main World @@ -125,7 +173,6 @@ error: response.error }, '*'); - console.log('[Lumen Content Script] Response sent to Main World:', response); } catch (error) { // Handle errors and send error response back to provider @@ -178,5 +225,11 @@ // Initialize: Inject the provider script injectProviderScript(); - console.log('[Lumen Content Script] Initialized on:', window.location.href); + // 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 index d053d76..27e6732 100644 --- a/src/inject.js +++ b/src/inject.js @@ -30,7 +30,6 @@ // Prevent double injection if (window.lumen) { - console.warn('[Lumen] Provider already injected'); return; } @@ -40,6 +39,79 @@ // 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) @@ -54,7 +126,6 @@ // Verify message is for Lumen and has valid UUID if (message.target !== 'lumen-provider') return; if (message.uuid !== LUMEN_UUID) { - console.warn('[Lumen] Invalid UUID in response, rejecting'); return; } @@ -111,7 +182,7 @@ pendingRequests.delete(requestId); reject(new Error('Request timeout')); } - }, 30000); + }, REQUEST_TIMEOUT_MS); }); } @@ -120,6 +191,7 @@ * 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 @@ -168,12 +240,49 @@ isConnected: () => true, /** - * Legacy support methods (some dApps may use these) + * Keplr-style provider methods (Cosmos dApps) */ - enable: async () => { - return lumenProvider.request({ method: 'eth_requestAccounts' }); + 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') { @@ -211,6 +320,6 @@ // Dispatch initialization event window.dispatchEvent(new Event('lumen#initialized')); + window.dispatchEvent(new Event('lumen_keystone_ready')); - console.log('[Lumen] Provider injected successfully'); })(); diff --git a/src/inpage.ts b/src/inpage.ts index d1c640d..966853f 100644 --- a/src/inpage.ts +++ b/src/inpage.ts @@ -3,6 +3,78 @@ (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) => { @@ -45,14 +117,19 @@ return { getAccounts: async () => { const key = await sendRequest('getKey', { chainId }) as any; + const keyData = key?.data ?? key ?? {}; return [{ - address: key.bech32Address, - algo: key.algo, - pubkey: key.pubKey, + address: keyData.bech32Address, + algo: keyData.algo, + pubkey: normalizePubKey(keyData.pubKey), }]; }, signDirect: async (signerAddress: string, signDoc: any) => { - return await sendRequest('signDirect', { signerAddress, signDoc }); + const response = await sendRequest('signDirect', { signerAddress, signDoc: normalizeSignDoc(signDoc) }); + return normalizeSignResponse(response); + }, + signAmino: async (signerAddress: string, signDoc: any) => { + return await sendRequest('signAmino', { signerAddress, signDoc }); } }; }, 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; + } +};