From 1470b9482e45b538fbba6fe3ea522c2ab5a02284 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 6 Feb 2026 21:15:47 +0530 Subject: [PATCH] feat: integrate Web3 wallet functionality and enhance state management - Added WalletInfoSection component to display connected wallet address, network, and balance. - Updated auth slice to manage primary wallet address derived from mnemonic. - Enhanced SkillManager to pass wallet address as load parameters for wallet skill. - Introduced utility function to derive EVM wallet address from mnemonic. - Updated dependencies for cryptographic operations related to wallet functionality. --- package.json | 2 + src-tauri/src/runtime/qjs_engine.rs | 24 +++- src-tauri/src/runtime/qjs_skill_instance.rs | 6 + src-tauri/src/runtime/types.rs | 5 + src/components/WalletInfoSection.tsx | 149 ++++++++++++++++++++ src/lib/skills/manager.ts | 16 ++- src/pages/Home.tsx | 4 + src/pages/Mnemonic.tsx | 5 +- src/store/authSlice.ts | 20 ++- src/store/index.ts | 8 +- src/utils/cryptoKeys.ts | 35 +++++ yarn.lock | 31 ++++ 12 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 src/components/WalletInfoSection.tsx diff --git a/package.json b/package.json index e43d9b45a..265aa7cba 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ }, "dependencies": { "@noble/hashes": "^2.0.1", + "@noble/secp256k1": "^2.0.0", "@reduxjs/toolkit": "^2.11.2", + "@scure/bip32": "^1.5.1", "@scure/bip39": "^2.0.1", "@sentry/react": "^10.38.0", "@tauri-apps/api": "^2", diff --git a/src-tauri/src/runtime/qjs_engine.rs b/src-tauri/src/runtime/qjs_engine.rs index cada75935..d76aaaf02 100644 --- a/src-tauri/src/runtime/qjs_engine.rs +++ b/src-tauri/src/runtime/qjs_engine.rs @@ -13,7 +13,7 @@ use crate::runtime::cron_scheduler::CronScheduler; use crate::runtime::manifest::SkillManifest; use crate::runtime::preferences::PreferencesStore; use crate::runtime::skill_registry::SkillRegistry; -use crate::runtime::types::{events, SkillSnapshot, SkillStatus, ToolResult}; +use crate::runtime::types::{events, SkillMessage, SkillSnapshot, SkillStatus, ToolResult}; use crate::runtime::qjs_skill_instance::{BridgeDeps, QjsSkillInstance}; use crate::services::tdlib_v8::storage::IdbStorage; @@ -448,7 +448,27 @@ impl RuntimeEngine { use crate::runtime::types::SkillMessage; match method { - "skill/load" => Ok(serde_json::json!({ "ok": true })), + "skill/load" => { + // Extract load params (exclude manifest and dataDir) and send to skill + let load_params: serde_json::Map = params + .as_object() + .map(|obj| { + obj.iter() + .filter(|(k, _)| k.as_str() != "manifest" && k.as_str() != "dataDir") + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }) + .unwrap_or_default(); + if !load_params.is_empty() { + let msg = SkillMessage::LoadParams { + params: serde_json::Value::Object(load_params), + }; + if let Err(e) = self.registry.send_message(skill_id, msg) { + log::warn!("[runtime] Failed to send LoadParams to skill '{}': {}", skill_id, e); + } + } + Ok(serde_json::json!({ "ok": true })) + } "setup/start" => { log::info!("[runtime] setup/start for '{}'", skill_id); diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index 12f50d65f..6a402a105 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -411,6 +411,12 @@ async fn handle_message( let result = handle_js_void_call(ctx, "onTick", "{}").await; let _ = reply.send(result); } + SkillMessage::LoadParams { params } => { + let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); + if let Err(e) = handle_js_void_call(ctx, "onLoad", ¶ms_str).await { + log::warn!("[skill:{}] onLoad failed (skill may not export it): {}", skill_id, e); + } + } SkillMessage::Rpc { method, params, reply } => { let result = match method.as_str() { "oauth/complete" => { diff --git a/src-tauri/src/runtime/types.rs b/src-tauri/src/runtime/types.rs index 2abe6945c..5eb79a8c8 100644 --- a/src-tauri/src/runtime/types.rs +++ b/src-tauri/src/runtime/types.rs @@ -92,6 +92,11 @@ pub enum SkillMessage { params: serde_json::Value, reply: tokio::sync::oneshot::Sender>, }, + /// Load params from frontend (e.g. wallet address for wallet skill). + /// Delivered after skill/load RPC; skill may export onLoad(params) to receive them. + LoadParams { + params: serde_json::Value, + }, } /// Result of executing a tool. diff --git a/src/components/WalletInfoSection.tsx b/src/components/WalletInfoSection.tsx new file mode 100644 index 000000000..dc168433a --- /dev/null +++ b/src/components/WalletInfoSection.tsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from 'react'; + +import { skillManager } from '../lib/skills/manager'; +import { useSkillConnectionStatus } from '../lib/skills/hooks'; +import { useAppSelector } from '../store/hooks'; +import { useUser } from '../hooks/useUser'; + +function truncateAddress(address: string): string { + if (!address || address.length < 12) return address; + return `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +export default function WalletInfoSection() { + const walletStatus = useSkillConnectionStatus('wallet'); + const { user } = useUser(); + const primaryAddress = useAppSelector(state => + user?._id ? state.auth.primaryWalletAddressByUser[user._id] : undefined + ); + + const [networkName, setNetworkName] = useState(null); + const [balance, setBalance] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const isConnected = walletStatus === 'connected' && !!primaryAddress; + + useEffect(() => { + if (!isConnected || !primaryAddress || !skillManager.isSkillRunning('wallet')) { + setLoading(false); + setNetworkName(null); + setBalance(null); + setError(null); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + + (async () => { + try { + const listRes = await skillManager.callTool('wallet', 'list_networks', {}); + const listText = listRes.content?.[0]?.text; + if (!listText || listRes.isError) { + if (!cancelled) setError('Could not load networks'); + return; + } + const listData = JSON.parse(listText) as { networks?: Array<{ chain_id: string; name: string; chain_type: string }> }; + const networks = listData.networks ?? []; + const firstEvm = networks.find((n: { chain_type: string }) => n.chain_type === 'evm'); + const first = firstEvm ?? networks[0]; + if (!first || cancelled) return; + + if (!cancelled) setNetworkName(first.name); + + const balanceRes = await skillManager.callTool('wallet', 'get_balance', { + address: primaryAddress, + chain_id: first.chain_id, + chain_type: first.chain_type ?? 'evm', + }); + const balanceText = balanceRes.content?.[0]?.text; + if (!balanceText || balanceRes.isError) { + if (!cancelled) setBalance('—'); + return; + } + const balanceData = JSON.parse(balanceText) as { + balance_eth?: string; + symbol?: string; + error?: string; + }; + if (balanceData.error && !cancelled) { + setBalance('—'); + return; + } + const eth = balanceData.balance_eth ?? '0'; + const symbol = balanceData.symbol ?? 'ETH'; + const value = parseFloat(eth); + const display = value < 0.0001 ? '0' : value.toFixed(4); + if (!cancelled) setBalance(`${display} ${symbol}`); + } catch (e) { + if (!cancelled) { + setError(e instanceof Error ? e.message : 'Failed to load wallet info'); + setBalance(null); + setNetworkName(null); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [isConnected, primaryAddress]); + + if (!isConnected) return null; + + return ( +
+
+ + + + Web3 Wallet +
+
+
+ Address + + {primaryAddress ? truncateAddress(primaryAddress) : '—'} + +
+
+ Network + + {loading ? ( + Loading… + ) : error ? ( + {error} + ) : ( + networkName ?? '—' + )} + +
+
+ Balance + + {loading ? ( + Loading… + ) : error ? ( + '—' + ) : ( + balance ?? '—' + )} + +
+
+
+ ); +} diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index 925670681..e3140204c 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -32,13 +32,21 @@ class SkillManager { private runtimes = new Map(); /** - * Get skill-specific load parameters (e.g., session data for Telegram) + * Get skill-specific load parameters (e.g., wallet address for wallet skill) */ - private getSkillLoadParams(_skillId: string): Record { + private getSkillLoadParams(skillId: string): Record { const params: Record = {}; - // For now, just return empty params - skill-specific session data - // will be handled through the skill's own setup process + if (skillId === "wallet") { + const state = store.getState(); + const userId = state.user.user?._id; + const primaryAddress = + userId && state.auth.primaryWalletAddressByUser?.[userId]; + if (primaryAddress) { + params.walletAddress = primaryAddress; + } + } + return params; } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index a2bd44149..fee66aa88 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import ConnectionIndicator from '../components/ConnectionIndicator'; import ModelDownloadProgress from '../components/ModelDownloadProgress'; import SkillsGrid from '../components/SkillsGrid'; +import WalletInfoSection from '../components/WalletInfoSection'; import { useUser } from '../hooks/useUser'; import { TELEGRAM_BOT_USERNAME } from '../utils/config'; import { openUrl } from '../utils/openUrl'; @@ -103,6 +104,9 @@ const Home = () => { + {/* Web3 wallet info (address, balance, network) — only when wallet skill is connected */} + + {/* Action buttons */}
{/* Settings */} diff --git a/src/pages/Mnemonic.tsx b/src/pages/Mnemonic.tsx index 43519fa42..4cf197837 100644 --- a/src/pages/Mnemonic.tsx +++ b/src/pages/Mnemonic.tsx @@ -2,10 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import LottieAnimation from '../components/LottieAnimation'; -import { setEncryptionKeyForUser } from '../store/authSlice'; +import { setEncryptionKeyForUser, setPrimaryWalletAddressForUser } from '../store/authSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { deriveAesKeyFromMnemonic, + deriveEvmAddressFromMnemonic, generateMnemonicPhrase, validateMnemonicPhrase, } from '../utils/cryptoKeys'; @@ -146,9 +147,11 @@ const Mnemonic = () => { } const aesKey = deriveAesKeyFromMnemonic(phraseToUse); + const walletAddress = deriveEvmAddressFromMnemonic(phraseToUse); if (user?._id) { dispatch(setEncryptionKeyForUser({ userId: user._id, key: aesKey })); + dispatch(setPrimaryWalletAddressForUser({ userId: user._id, address: walletAddress })); } navigate('/home'); diff --git a/src/store/authSlice.ts b/src/store/authSlice.ts index 3180bfeb3..f90e50d33 100644 --- a/src/store/authSlice.ts +++ b/src/store/authSlice.ts @@ -11,6 +11,8 @@ export interface AuthState { isAnalyticsEnabledByUser: Record; /** AES encryption key (hex) derived from mnemonic, per user id */ encryptionKeyByUser: Record; + /** Primary EVM wallet address (0x...) derived from mnemonic, per user id */ + primaryWalletAddressByUser: Record; } const initialState: AuthState = { @@ -18,6 +20,7 @@ const initialState: AuthState = { isOnboardedByUser: {}, isAnalyticsEnabledByUser: {}, encryptionKeyByUser: {}, + primaryWalletAddressByUser: {}, }; const authSlice = createSlice({ @@ -30,6 +33,7 @@ const authSlice = createSlice({ _clearToken: state => { state.token = null; state.encryptionKeyByUser = {}; + state.primaryWalletAddressByUser = {}; }, setOnboardedForUser: (state, action: PayloadAction<{ userId: string; value: boolean }>) => { const { userId, value } = action.payload; @@ -43,6 +47,13 @@ const authSlice = createSlice({ const { userId, key } = action.payload; state.encryptionKeyByUser[userId] = key; }, + setPrimaryWalletAddressForUser: ( + state, + action: PayloadAction<{ userId: string; address: string }> + ) => { + const { userId, address } = action.payload; + state.primaryWalletAddressByUser[userId] = address; + }, }, }); @@ -53,6 +64,11 @@ export const clearToken = createAsyncThunk('auth/clearToken', async (_, { dispat dispatch(clearTeamState()); }); -export const { setToken, setOnboardedForUser, setAnalyticsForUser, setEncryptionKeyForUser } = - authSlice.actions; +export const { + setToken, + setOnboardedForUser, + setAnalyticsForUser, + setEncryptionKeyForUser, + setPrimaryWalletAddressForUser, +} = authSlice.actions; export default authSlice.reducer; diff --git a/src/store/index.ts b/src/store/index.ts index 03f7ac119..54a0c7987 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -25,7 +25,13 @@ import userReducer from './userSlice'; const authPersistConfig = { key: 'auth', storage, - whitelist: ['token', 'isOnboardedByUser', 'isAnalyticsEnabledByUser', 'encryptionKeyByUser'], + whitelist: [ + 'token', + 'isOnboardedByUser', + 'isAnalyticsEnabledByUser', + 'encryptionKeyByUser', + 'primaryWalletAddressByUser', + ], }; // Persist config for AI state (config only) diff --git a/src/utils/cryptoKeys.ts b/src/utils/cryptoKeys.ts index 7cf64964b..9a4416c2e 100644 --- a/src/utils/cryptoKeys.ts +++ b/src/utils/cryptoKeys.ts @@ -1,6 +1,9 @@ +import { getPublicKey } from '@noble/secp256k1'; +import { keccak_256 } from '@noble/hashes/sha3.js'; import { pbkdf2 } from '@noble/hashes/pbkdf2.js'; import { sha256 } from '@noble/hashes/sha2.js'; import { bytesToHex } from '@noble/hashes/utils.js'; +import { HDKey } from '@scure/bip32'; import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english.js'; @@ -33,3 +36,35 @@ export function deriveAesKeyFromMnemonic(mnemonic: string): string { return bytesToHex(derivedKey); } + +/** BIP44 path for first Ethereum account: m/44'/60'/0'/0/0 */ +const EVM_DERIVATION_PATH = "m/44'/60'/0'/0/0"; + +/** + * Derive the first EVM wallet address (Ethereum BIP44) from a mnemonic phrase. + * Uses path m/44'/60'/0'/0/0. Returns a checksummed 0x-prefixed address. + */ +export function deriveEvmAddressFromMnemonic(mnemonic: string): string { + const seed = mnemonicToSeedSync(mnemonic); + const hdkey = HDKey.fromMasterSeed(seed); + const derived = hdkey.derive(EVM_DERIVATION_PATH); + const privateKey = derived.privateKey; + if (!privateKey) throw new Error('Failed to derive private key'); + // Ethereum address = keccak256(uncompressed public key without 0x04)[12:] + const pubKey = getPublicKey(privateKey, false); // uncompressed, 65 bytes + const hash = keccak_256(pubKey.slice(1)); + const addressBytes = hash.slice(-20); + const hex = bytesToHex(addressBytes); + return toChecksumAddress('0x' + hex); +} + +/** Simple checksum: lowercase with 0x, then capitalize by hash. */ +function toChecksumAddress(address: string): string { + const a = address.replace(/^0x/i, '').toLowerCase(); + const hash = bytesToHex(keccak_256(new TextEncoder().encode(a))); + let result = '0x'; + for (let i = 0; i < 40; i++) { + result += parseInt(hash[i], 16) >= 8 ? a[i].toUpperCase() : a[i]; + } + return result; +} diff --git a/yarn.lock b/yarn.lock index 316fe5ecc..f84d0f424 100644 --- a/yarn.lock +++ b/yarn.lock @@ -499,11 +499,28 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@noble/curves@~1.9.0": + version "1.9.7" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.9.7.tgz#79d04b4758a43e4bca2cbdc62e7771352fa6b951" + integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw== + dependencies: + "@noble/hashes" "1.8.0" + +"@noble/hashes@1.8.0", "@noble/hashes@~1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + "@noble/hashes@2.0.1", "@noble/hashes@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== +"@noble/secp256k1@^2.0.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-2.3.0.tgz#ddfe6e853472fb88cba4d5e59b7067adc1e64adf" + integrity sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -695,6 +712,20 @@ resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98" integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== +"@scure/base@~1.2.5": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.2.6.tgz#ca917184b8231394dd8847509c67a0be522e59f6" + integrity sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg== + +"@scure/bip32@^1.5.1": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.7.0.tgz#b8683bab172369f988f1589640e53c4606984219" + integrity sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw== + dependencies: + "@noble/curves" "~1.9.0" + "@noble/hashes" "~1.8.0" + "@scure/base" "~1.2.5" + "@scure/bip39@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-2.0.1.tgz#47a6dc15e04faf200041239d46ae3bb7c3c96add"