mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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(¶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" => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user