feat(vault): add user vault factory, non-custodial deposit/withdraw and session key UI

This commit is contained in:
Bot
2026-08-31 01:41:57 +08:00
parent 7e554402b2
commit 4890329f99
6 changed files with 607 additions and 9 deletions
+71 -1
View File
@@ -9,9 +9,14 @@ interface Web3ContextType {
chainId: number | null; chainId: number | null;
isCorrectNetwork: boolean; isCorrectNetwork: boolean;
wusdBalance: string; wusdBalance: string;
vaultAddress: string | null;
isSessionActive: boolean;
connectWallet: () => Promise<void>; connectWallet: () => Promise<void>;
switchNetwork: () => Promise<void>; switchNetwork: () => Promise<void>;
refreshBalance: () => Promise<void>; refreshBalance: () => Promise<void>;
depositToVault: (amount: string) => Promise<string>;
withdrawFromVault: (amount: string) => Promise<string>;
enableSessionKey: () => Promise<void>;
getMarketContract: (address: string) => ethers.Contract | null; getMarketContract: (address: string) => ethers.Contract | null;
getCollateralContract: () => ethers.Contract | null; getCollateralContract: () => ethers.Contract | null;
} }
@@ -23,9 +28,14 @@ const Web3Context = createContext<Web3ContextType>({
chainId: null, chainId: null,
isCorrectNetwork: false, isCorrectNetwork: false,
wusdBalance: '0', wusdBalance: '0',
vaultAddress: null,
isSessionActive: false,
connectWallet: async () => {}, connectWallet: async () => {},
switchNetwork: async () => {}, switchNetwork: async () => {},
refreshBalance: async () => {}, refreshBalance: async () => {},
depositToVault: async () => '',
withdrawFromVault: async () => '',
enableSessionKey: async () => {},
getMarketContract: () => null, getMarketContract: () => null,
getCollateralContract: () => null, getCollateralContract: () => null,
}); });
@@ -36,9 +46,23 @@ export const Web3Provider: React.FC<{ children: React.ReactNode }> = ({ children
const [signer, setSigner] = useState<ethers.Signer | null>(null); const [signer, setSigner] = useState<ethers.Signer | null>(null);
const [chainId, setChainId] = useState<number | null>(null); const [chainId, setChainId] = useState<number | null>(null);
const [wusdBalance, setWusdBalance] = useState<string>('0'); const [wusdBalance, setWusdBalance] = useState<string>('0');
const [vaultAddress, setVaultAddress] = useState<string | null>(null);
const [isSessionActive, setIsSessionActive] = useState<boolean>(false);
const isCorrectNetwork = chainId === ROBINHOOD_TESTNET_CHAIN.id; const isCorrectNetwork = chainId === ROBINHOOD_TESTNET_CHAIN.id;
const predictUserVault = async (userAddr: string, prov: ethers.BrowserProvider) => {
try {
const factoryAddr = DEPLOYMENTS.robinhoodTestnet.contracts.vaultFactory;
if (!factoryAddr) return;
const factoryContract = new ethers.Contract(factoryAddr, ABIS.WTFVaultFactory, prov);
const predicted = await factoryContract.predictVaultAddress(userAddr);
setVaultAddress(predicted);
} catch (e) {
console.error('Failed to predict vault address:', e);
}
};
const refreshBalance = async () => { const refreshBalance = async () => {
if (!account || !provider) return; if (!account || !provider) return;
try { try {
@@ -73,10 +97,13 @@ export const Web3Provider: React.FC<{ children: React.ReactNode }> = ({ children
(window as any).ethereum.on('accountsChanged', async (accs: string[]) => { (window as any).ethereum.on('accountsChanged', async (accs: string[]) => {
if (accs.length > 0) { if (accs.length > 0) {
setAccount(accs[0]); setAccount(accs[0]);
setSigner(await browserProvider.getSigner()); const currentSigner = await browserProvider.getSigner();
setSigner(currentSigner);
predictUserVault(accs[0], browserProvider);
} else { } else {
setAccount(null); setAccount(null);
setSigner(null); setSigner(null);
setVaultAddress(null);
} }
}); });
@@ -157,6 +184,44 @@ export const Web3Provider: React.FC<{ children: React.ReactNode }> = ({ children
); );
}; };
const depositToVault = async (amountStr: string): Promise<string> => {
if (!signer || !account || !vaultAddress) throw new Error('Wallet/Vault not ready');
const collateral = new ethers.Contract(
DEPLOYMENTS.robinhoodTestnet.contracts.collateral,
ABIS.MockERC20,
signer
);
const amountWei = ethers.parseUnits(amountStr, 18);
const tx = await collateral.transfer(vaultAddress, amountWei);
await tx.wait();
refreshBalance();
return tx.hash;
};
const withdrawFromVault = async (amountStr: string): Promise<string> => {
if (!signer || !account || !vaultAddress) throw new Error('Wallet/Vault not ready');
const vault = new ethers.Contract(vaultAddress, ABIS.WTFUserVault, signer);
const amountWei = ethers.parseUnits(amountStr, 18);
const tx = await vault.withdraw(
DEPLOYMENTS.robinhoodTestnet.contracts.collateral,
account,
amountWei
);
await tx.wait();
refreshBalance();
return tx.hash;
};
const enableSessionKey = async () => {
if (!signer || !account || !vaultAddress) throw new Error('Wallet/Vault not ready');
const vault = new ethers.Contract(vaultAddress, ABIS.WTFUserVault, signer);
const relayerAdmin = DEPLOYMENTS.robinhoodTestnet.governance.admin;
const duration = 24 * 3600; // 24 hours
const tx = await vault.authorizeSession(relayerAdmin, duration);
await tx.wait();
setIsSessionActive(true);
};
return ( return (
<Web3Context.Provider <Web3Context.Provider
value={{ value={{
@@ -166,9 +231,14 @@ export const Web3Provider: React.FC<{ children: React.ReactNode }> = ({ children
chainId, chainId,
isCorrectNetwork, isCorrectNetwork,
wusdBalance, wusdBalance,
vaultAddress,
isSessionActive,
connectWallet, connectWallet,
switchNetwork, switchNetwork,
refreshBalance, refreshBalance,
depositToVault,
withdrawFromVault,
enableSessionKey,
getMarketContract, getMarketContract,
getCollateralContract, getCollateralContract,
}} }}
@@ -1,9 +1,51 @@
import React from 'react'; import React, { useState } from 'react';
import { useWeb3 } from '../../context/Web3Context'; import { useWeb3 } from '../../context/Web3Context';
import { PieChart, Clock, ArrowUpRight, ArrowDownLeft, ShieldCheck, History } from 'lucide-react'; import { PieChart, Clock, ArrowUpRight, ArrowDownLeft, ShieldCheck, History, KeyRound, ArrowRightLeft, Loader2, Check } from 'lucide-react';
export const PortfolioView: React.FC = () => { export const PortfolioView: React.FC = () => {
const { account, wusdBalance } = useWeb3(); const { account, wusdBalance, vaultAddress, isSessionActive, depositToVault, withdrawFromVault, enableSessionKey } = useWeb3();
const [depositAmount, setDepositAmount] = useState<string>('500');
const [withdrawAmount, setWithdrawAmount] = useState<string>('100');
const [loading, setLoading] = useState<boolean>(false);
const [actionMsg, setActionMsg] = useState<string | null>(null);
const handleDeposit = async () => {
try {
setLoading(true);
setActionMsg(null);
const hash = await depositToVault(depositAmount);
setActionMsg(`🎉 成功存入 ${depositAmount} WUSD 到专属非托管金库!Tx: ${hash.slice(0, 10)}...`);
} catch (e: any) {
alert(e.message || '充值失败');
} finally {
setLoading(false);
}
};
const handleWithdraw = async () => {
try {
setLoading(true);
setActionMsg(null);
const hash = await withdrawFromVault(withdrawAmount);
setActionMsg(`🎉 成功从金库提现 ${withdrawAmount} WUSD 到钱包!Tx: ${hash.slice(0, 10)}...`);
} catch (e: any) {
alert(e.message || '提现失败');
} finally {
setLoading(false);
}
};
const handleEnableSession = async () => {
try {
setLoading(true);
await enableSessionKey();
setActionMsg('⚡ Session Key 免密连击授权成功!24小时内高频交易零弹窗!');
} catch (e: any) {
alert(e.message || '授权失败');
} finally {
setLoading(false);
}
};
const MOCK_POSITIONS = [ const MOCK_POSITIONS = [
{ {
@@ -79,12 +121,96 @@ export const PortfolioView: React.FC = () => {
</div> </div>
<div className="bg-[#0f111d] border border-slate-800 rounded-2xl p-6 shadow-xl"> <div className="bg-[#0f111d] border border-slate-800 rounded-2xl p-6 shadow-xl">
<span className="text-xs text-slate-400 block font-medium"> (Active Positions)</span> <span className="text-xs text-slate-400 block font-medium"> (AA User Vault)</span>
<span className="text-2xl font-black font-mono text-slate-200 mt-1 block">2 </span> <span className="text-xs font-mono text-slate-300 truncate block mt-1">
<div className="text-xs text-slate-500 mt-2"></div> {vaultAddress || '连接钱包后自动生成'}
</span>
<div className="flex items-center space-x-1 text-xs text-emerald-400 font-semibold mt-2">
<ShieldCheck className="w-3.5 h-3.5" />
<span>100% </span>
</div>
</div> </div>
</div> </div>
{/* Non-Custodial Vault Operations Panel */}
<div className="bg-[#0f111d] border border-cyan-900/30 rounded-2xl p-6 shadow-xl space-y-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 border-b border-slate-800 pb-4">
<div className="flex items-center space-x-3">
<div className="p-2.5 bg-cyan-500/10 rounded-xl border border-cyan-500/20 text-cyan-400">
<KeyRound className="w-5 h-5" />
</div>
<div>
<h2 className="font-bold text-base text-white"> (AA Smart Vault)</h2>
<p className="text-xs text-slate-400"> Session Key 0 </p>
</div>
</div>
<button
onClick={handleEnableSession}
disabled={loading || isSessionActive || !account}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all flex items-center space-x-1.5 ${
isSessionActive
? 'bg-emerald-950/60 text-emerald-400 border border-emerald-800/50 cursor-default'
: 'bg-gradient-to-r from-cyan-500 to-indigo-600 hover:from-cyan-400 hover:to-indigo-500 text-white shadow-md shadow-cyan-500/20'
}`}
>
<KeyRound className="w-3.5 h-3.5" />
<span>{isSessionActive ? '⚡ 免密 Session 已激活 (24H 有效)' : '开启免密连击交易 (Session Key)'}</span>
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
{/* Deposit */}
<div className="bg-slate-950/60 border border-slate-800 rounded-xl p-4 space-y-3">
<span className="text-xs font-bold text-slate-300 block"> (Deposit to Vault)</span>
<div className="flex space-x-2">
<input
type="number"
value={depositAmount}
onChange={(e) => setDepositAmount(e.target.value)}
placeholder="500"
className="flex-1 bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-xs font-mono text-slate-100 focus:outline-none focus:border-cyan-500"
/>
<button
onClick={handleDeposit}
disabled={loading || !account || !depositAmount}
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-500 text-white rounded-lg text-xs font-bold transition-colors disabled:opacity-50"
>
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : '立即充值'}
</button>
</div>
</div>
{/* Withdraw */}
<div className="bg-slate-950/60 border border-slate-800 rounded-xl p-4 space-y-3">
<span className="text-xs font-bold text-slate-300 block"> EOA (Instant Withdraw)</span>
<div className="flex space-x-2">
<input
type="number"
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
placeholder="100"
className="flex-1 bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-xs font-mono text-slate-100 focus:outline-none focus:border-cyan-500"
/>
<button
onClick={handleWithdraw}
disabled={loading || !account || !withdrawAmount}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-600 rounded-lg text-xs font-bold transition-colors disabled:opacity-50"
>
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : '提取本金'}
</button>
</div>
</div>
</div>
{actionMsg && (
<div className="p-3 bg-emerald-950/30 border border-emerald-800/40 rounded-xl text-xs text-emerald-300 flex items-center space-x-2">
<Check className="w-4 h-4 text-emerald-400" />
<span>{actionMsg}</span>
</div>
)}
</div>
{/* Positions Table */} {/* Positions Table */}
<div className="bg-[#0f111d] border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4"> <div className="bg-[#0f111d] border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4">
<div className="flex items-center space-x-2 border-b border-slate-800/80 pb-4"> <div className="flex items-center space-x-2 border-b border-slate-800/80 pb-4">
@@ -0,0 +1,294 @@
[
{
"inputs": [
{
"internalType": "address",
"name": "_owner",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "from",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "Deposited",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "target",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "value",
"type": "uint256"
},
{
"indexed": false,
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"name": "Executed",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "sessionKey",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "expiry",
"type": "uint256"
}
],
"name": "SessionAuthorized",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "sessionKey",
"type": "address"
}
],
"name": "SessionRevoked",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "Withdrawn",
"type": "event"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "address",
"name": "spender",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "approveToken",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "sessionKey",
"type": "address"
},
{
"internalType": "uint256",
"name": "duration",
"type": "uint256"
}
],
"name": "authorizeSession",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "target",
"type": "address"
},
{
"internalType": "uint256",
"name": "value",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"name": "execute",
"outputs": [
{
"internalType": "bytes",
"name": "",
"type": "bytes"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [],
"name": "factory",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "isRelayer",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "sessionKey",
"type": "address"
}
],
"name": "revokeSession",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "sessionExpiry",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "withdraw",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"stateMutability": "payable",
"type": "receive"
}
]
@@ -0,0 +1,103 @@
[
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "user",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "vault",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "vaultIndex",
"type": "uint256"
}
],
"name": "VaultCreated",
"type": "event"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"name": "allVaults",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "user",
"type": "address"
}
],
"name": "getOrCreateVault",
"outputs": [
{
"internalType": "address",
"name": "vault",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "getVault",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "user",
"type": "address"
}
],
"name": "predictVaultAddress",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
}
]
+5 -1
View File
@@ -1,12 +1,16 @@
import WTFControllerV2Abi from './abis/WTFControllerV2.json'; import WTFControllerV2Abi from './abis/WTFControllerV2.json';
import MockERC20Abi from './abis/MockERC20.json'; import MockERC20Abi from './abis/MockERC20.json';
import WTFMarketV2Abi from './abis/WTFMarketV2.json'; import WTFMarketV2Abi from './abis/WTFMarketV2.json';
import WTFVaultFactoryAbi from './abis/WTFVaultFactory.json';
import WTFUserVaultAbi from './abis/WTFUserVault.json';
import robinhoodTestnetDeployment from './robinhoodTestnet.json'; import robinhoodTestnetDeployment from './robinhoodTestnet.json';
export const ABIS = { export const ABIS = {
WTFControllerV2: WTFControllerV2Abi, WTFControllerV2: WTFControllerV2Abi,
MockERC20: MockERC20Abi, MockERC20: MockERC20Abi,
WTFMarketV2: WTFMarketV2Abi WTFMarketV2: WTFMarketV2Abi,
WTFVaultFactory: WTFVaultFactoryAbi,
WTFUserVault: WTFUserVaultAbi
}; };
export const DEPLOYMENTS = { export const DEPLOYMENTS = {
+2 -1
View File
@@ -7,7 +7,8 @@
"collateral": "0xe776e957953EA69b7Eaa9d7d4098aBC076bDD5E7", "collateral": "0xe776e957953EA69b7Eaa9d7d4098aBC076bDD5E7",
"powerLDACurve": "0xF0E189974c413506098AFB6Bc6Bf4C6715fBf7B1", "powerLDACurve": "0xF0E189974c413506098AFB6Bc6Bf4C6715fBf7B1",
"controllerImplementation": "0x2Aeca49669e9E4E6CdCe46A1bc9bf136765A4f75", "controllerImplementation": "0x2Aeca49669e9E4E6CdCe46A1bc9bf136765A4f75",
"controllerProxy": "0xc0E24E152771C588B21AEB654b30B1cBAf381c1a" "controllerProxy": "0xc0E24E152771C588B21AEB654b30B1cBAf381c1a",
"vaultFactory": "0x89401e07296267c01017cA150D5Ec8883a78e0B2"
}, },
"governance": { "governance": {
"admin": "0x6cddF384792C77219fc25C454Cfe264757842830", "admin": "0x6cddF384792C77219fc25C454Cfe264757842830",