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.
This commit is contained in:
M3gA-Mind
2026-02-06 21:15:47 +05:30
parent e0cd0930a1
commit 1470b9482e
12 changed files with 295 additions and 10 deletions
+2
View File
@@ -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",
+22 -2
View File
@@ -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<String, serde_json::Value> = 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);
@@ -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(&params).unwrap_or_else(|_| "{}".to_string());
if let Err(e) = handle_js_void_call(ctx, "onLoad", &params_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" => {
+5
View File
@@ -92,6 +92,11 @@ pub enum SkillMessage {
params: serde_json::Value,
reply: tokio::sync::oneshot::Sender<Result<serde_json::Value, String>>,
},
/// 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.
+149
View File
@@ -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<string | null>(null);
const [balance, setBalance] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="glass rounded-3xl p-4 shadow-large animate-fade-up mt-4">
<div className="flex items-center gap-2 mb-3">
<svg
className="w-5 h-5 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
<span className="font-semibold text-sm">Web3 Wallet</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between items-center">
<span className="opacity-70">Address</span>
<span className="font-mono text-xs" title={primaryAddress ?? ''}>
{primaryAddress ? truncateAddress(primaryAddress) : '—'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="opacity-70">Network</span>
<span>
{loading ? (
<span className="opacity-60">Loading</span>
) : error ? (
<span className="text-coral-500 text-xs">{error}</span>
) : (
networkName ?? '—'
)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="opacity-70">Balance</span>
<span>
{loading ? (
<span className="opacity-60">Loading</span>
) : error ? (
'—'
) : (
balance ?? '—'
)}
</span>
</div>
</div>
</div>
);
}
+12 -4
View File
@@ -32,13 +32,21 @@ class SkillManager {
private runtimes = new Map<string, SkillRuntime>();
/**
* 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<string, unknown> {
private getSkillLoadParams(skillId: string): Record<string, unknown> {
const params: Record<string, unknown> = {};
// 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;
}
+4
View File
@@ -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 = () => {
</button>
</div>
{/* Web3 wallet info (address, balance, network) — only when wallet skill is connected */}
<WalletInfoSection />
{/* Action buttons */}
<div className="glass rounded-3xl p-0 shadow-large animate-fade-up mt-4 overflow-hidden">
{/* Settings */}
+4 -1
View File
@@ -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');
+18 -2
View File
@@ -11,6 +11,8 @@ export interface AuthState {
isAnalyticsEnabledByUser: Record<string, boolean>;
/** AES encryption key (hex) derived from mnemonic, per user id */
encryptionKeyByUser: Record<string, string>;
/** Primary EVM wallet address (0x...) derived from mnemonic, per user id */
primaryWalletAddressByUser: Record<string, string>;
}
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;
+7 -1
View File
@@ -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)
+35
View File
@@ -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;
}
+31
View File
@@ -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"