(
Stop Loss
{(() => {
const isTrailing = hasTrailingStop(signal) || (
- signal.order_side === 'buy'
- ? signal.stop_loss > signal.entry_price
+ signal.order_side === 'buy'
+ ? signal.stop_loss > signal.entry_price
: signal.stop_loss < signal.entry_price
);
-
+
return isTrailing ? (
{signal.order_side === 'buy' ? '↑' : '↓'}
@@ -182,19 +226,19 @@ export const SignalCard = forwardRef(
].filter(({ price }) => price != null).map(({ key, price, hit }) => {
const isClosed = ['completed', 'stopped', 'closed'].includes(signal.status || 'active');
const priceReachedTP = signal.current_price && (
- signal.order_side === 'buy'
- ? signal.current_price >= price
+ signal.order_side === 'buy'
+ ? signal.current_price >= price
: signal.current_price <= price
);
const isHit = hit || (isClosed && priceReachedTP);
-
+
const tpPercent = signal.order_side?.toLowerCase() === 'sell'
? ((signal.entry_price - price) / signal.entry_price) * 100
: ((price - signal.entry_price) / signal.entry_price) * 100;
const lockedInPercent = isHit ? (tpPercent * 0.3333) : null;
const displayedLockedIn = lockedInPercent !== null ? applyLeverage(lockedInPercent).toFixed(2) : null;
const displayedPotential = applyLeverage(tpPercent * 0.3333).toFixed(2);
-
+
return (
@@ -245,7 +289,7 @@ export const SignalCard = forwardRef(
)}
-
+
{timelineOpen && (
{timeline.map((event, idx) => {
@@ -256,10 +300,10 @@ export const SignalCard = forwardRef
(
RefreshCw,
CheckCircle2,
}[event.icon];
-
+
return (
-
{IconComponent &&
}
@@ -299,9 +343,8 @@ export const SignalCard = forwardRef
(
<>
Unrealized P/L
-
= 0 ? 'text-[hsl(142,76%,58%)]' : 'text-[hsl(348,83%,58%)]'
- }`}>
+
= 0 ? 'text-[hsl(142,76%,58%)]' : 'text-[hsl(348,83%,58%)]'
+ }`}>
{applyLeverage(signal.profit_loss_percent || 0) >= 0 ? '+' : ''}{applyLeverage(signal.profit_loss_percent || 0).toFixed(2).replace('.', ',')}%
{(signal.profit_loss_percent || 0) >= 0 && }
@@ -317,9 +360,8 @@ export const SignalCard = forwardRef
(
<>
Realized P/L
-
= 0 ? 'text-[hsl(142,76%,58%)]' : 'text-[hsl(348,83%,58%)]'
- }`}>
+
= 0 ? 'text-[hsl(142,76%,58%)]' : 'text-[hsl(348,83%,58%)]'
+ }`}>
{applyLeverage(signal.realized_pnl_percent || 0) >= 0 ? '+' : ''}{applyLeverage(signal.realized_pnl_percent || 0).toFixed(2).replace('.', ',')}%
{(signal.realized_pnl_percent || 0) >= 0 && }
diff --git a/src/apps/insights/hooks/useTradingSignals.ts b/src/apps/insights/hooks/useTradingSignals.ts
index 3b449aeb..d88d469f 100644
--- a/src/apps/insights/hooks/useTradingSignals.ts
+++ b/src/apps/insights/hooks/useTradingSignals.ts
@@ -27,17 +27,18 @@ export const useTradingSignals = (options: UseTradingSignalsOptions = {}) => {
if (!isRefresh) {
setLoading(true);
}
+
const result = await getTradingSignals();
console.log('🔍 [useTradingSignals] API response:', result);
-
+
if (result.error) {
throw result.error;
}
-
+
// Firebase function returns { signals: [...] }
const signalsArray = (result.signals || result.data || []) as TradingSignal[];
console.log(`✅ [useTradingSignals] Loaded ${signalsArray.length} signals:`, signalsArray);
-
+
// Log first signal structure for debugging
if (signalsArray.length > 0) {
const firstSignal = signalsArray[0];
@@ -51,10 +52,10 @@ export const useTradingSignals = (options: UseTradingSignalsOptions = {}) => {
entry_price: firstSignal.entry_price,
});
}
-
+
setSignals(signalsArray);
setError(null);
-
+
// Mark initial load as complete
if (isInitialLoad) {
setIsInitialLoad(false);
@@ -66,7 +67,7 @@ export const useTradingSignals = (options: UseTradingSignalsOptions = {}) => {
} finally {
setLoading(false);
}
- }, [enabled]);
+ }, [enabled, isInitialLoad]);
useEffect(() => {
if (!enabled) {
diff --git a/src/apps/insights/index.tsx b/src/apps/insights/index.tsx
index 4491783e..b4904052 100644
--- a/src/apps/insights/index.tsx
+++ b/src/apps/insights/index.tsx
@@ -124,6 +124,7 @@ const App = () => {
enabled: Boolean(eoaAddress),
pollIntervalMs: SUBSCRIPTION_POLL_INTERVAL,
});
+
const [isAwaitingSubscription, setIsAwaitingSubscription] = useState(false);
const [showManageMenu, setShowManageMenu] = useState(false);
const manageMenuRef = useRef
(null);
@@ -161,7 +162,7 @@ const App = () => {
const consentDate = new Date(consent.timestamp);
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
-
+
if (consentDate > oneYearAgo) {
setConsentGiven(true);
} else {
@@ -187,22 +188,22 @@ const App = () => {
if (signals.length > 0 && !loading) {
const openSignals = signals.filter(s => s.status === 'active');
-
+
// Only fetch sparklines for signals we haven't fetched yet
const signalsNeedingSparklines = openSignals.filter(signal => {
const alreadyFetched = fetchedSparklineIdsRef.current.has(signal.id);
const hasData = sparklineDataMap[signal.id] && sparklineDataMap[signal.id].length > 0;
const isLoading = sparklineLoading[signal.id];
-
+
if (alreadyFetched || hasData || isLoading) {
return false;
}
-
+
// Mark as fetched to prevent duplicate requests
fetchedSparklineIdsRef.current.add(signal.id);
return true;
});
-
+
if (signalsNeedingSparklines.length > 0) {
console.log(`📊 [Sparkline] Initial fetch for ${signalsNeedingSparklines.length} new signals`);
fetchSparklines(signalsNeedingSparklines);
@@ -258,7 +259,7 @@ const App = () => {
}
return value;
}, [openSignals]);
-
+
const closedTotalPnL = useMemo(() => {
const closedPnL = calculateTotalPnL(closedSignals);
const openRealizedPnL = calculateOpenRealizedPnL();
@@ -266,7 +267,7 @@ const App = () => {
console.log(`💰 [PnL] closedTotalPnL: ${value}% (closed: ${closedPnL}%, open realized: ${openRealizedPnL}%)`);
return value;
}, [closedSignals, openSignals]);
-
+
const floatingPnL = useMemo(() => {
const value = openTotalPnL + closedTotalPnL;
console.log(`💰 [PnL] floatingPnL: ${value}% (open: ${openTotalPnL}%, closed: ${closedTotalPnL}%)`);
@@ -305,7 +306,7 @@ const App = () => {
};
const handleRefreshSubscription = useCallback(() => {
- refetchSubscription().catch(() => {});
+ refetchSubscription().catch(() => { });
}, [refetchSubscription]);
const handleSubscribeClick = useCallback(() => {
@@ -324,7 +325,7 @@ const App = () => {
setIsAwaitingSubscription(true);
startPolling();
- refetchSubscription().catch(() => {});
+ refetchSubscription().catch(() => { });
if (!isNativeApp) {
const confirmed = window.confirm(
@@ -642,9 +643,9 @@ const App = () => {
) : (
feedEvents.map((event) => (
-
diff --git a/src/apps/insights/utils/logoUtils.ts b/src/apps/insights/utils/logoUtils.ts
index 40361e6c..3339cf47 100644
--- a/src/apps/insights/utils/logoUtils.ts
+++ b/src/apps/insights/utils/logoUtils.ts
@@ -17,10 +17,10 @@ export const loadLogoCache = (): Record => {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) return {};
-
+
const cache: Record = JSON.parse(cached);
const now = Date.now();
-
+
// Prune expired entries
const pruned: Record = {};
Object.entries(cache).forEach(([symbol, entry]) => {
@@ -29,7 +29,7 @@ export const loadLogoCache = (): Record => {
pruned[symbol] = entry;
}
});
-
+
return pruned;
} catch (err) {
console.error('Error loading logo cache:', err);
@@ -45,13 +45,16 @@ export const saveLogoCache = (cache: Record) => {
}
};
-export const getCachedLogo = (symbol: string, cache: Record): string | null => {
+export const getCachedLogo = (
+ symbol: string,
+ cache: Record
+): string | null => {
const entry = cache[symbol];
if (!entry) return null;
-
+
const ttl = entry.miss ? MISS_TTL_MS : HIT_TTL_MS;
if (Date.now() - entry.updatedAt >= ttl) return null;
-
+
if (entry.miss) return null; // Don't return misses as valid URLs
return entry.url || null;
};
@@ -59,58 +62,73 @@ export const getCachedLogo = (symbol: string, cache: Record => {
+export const fetchLogoFromMobula = async (
+ symbol: string
+): Promise => {
try {
console.log(`🔍 Querying Mobula for ${symbol}`);
const response = await fetch(
- `https://explorer-api.mobula.io/api/1/search?mode=og&sortBy=volume_24h&input=${symbol}`
+ `https://api.mobula.io/api/1/search?mode=og&sortBy=volume_24h&input=${symbol}`,
+ {
+ method: 'GET',
+ headers: {
+ Authorization: `${import.meta.env.VITE_MOBULA_API_KEY || 'your_api_key_here'}`,
+ },
+ }
);
const data = await response.json();
-
+
if (data?.data?.length > 0) {
console.log(`📊 ${symbol} results: ${data.data.length} items`);
-
+
// Selection strategy: prefer exact symbol match with highest market_cap
let selectedToken = null;
-
+
// First, try exact symbol match (case-insensitive)
const exactMatches = data.data.filter(
(token: any) => token.symbol?.toUpperCase() === symbol
);
-
+
if (exactMatches.length > 0) {
// Pick highest market_cap, fallback to liquidity, then volume
selectedToken = exactMatches.reduce((best: any, current: any) => {
- const bestScore = best.market_cap || best.liquidity || best.volume || 0;
- const currentScore = current.market_cap || current.liquidity || current.volume || 0;
+ const bestScore =
+ best.market_cap || best.liquidity || best.volume || 0;
+ const currentScore =
+ current.market_cap || current.liquidity || current.volume || 0;
return currentScore > bestScore ? current : best;
});
- console.log(`✓ ${symbol} exact match: ${selectedToken.name} (${selectedToken.symbol}) - market_cap: ${selectedToken.market_cap}`);
+ console.log(
+ `✓ ${symbol} exact match: ${selectedToken.name} (${selectedToken.symbol}) - market_cap: ${selectedToken.market_cap}`
+ );
} else {
// Fallback: pick item with highest market_cap that has a logo
selectedToken = data.data
.filter((token: any) => token.logo)
.reduce((best: any, current: any) => {
if (!best) return current;
- const bestScore = best.market_cap || best.liquidity || best.volume || 0;
- const currentScore = current.market_cap || current.liquidity || current.volume || 0;
+ const bestScore =
+ best.market_cap || best.liquidity || best.volume || 0;
+ const currentScore =
+ current.market_cap || current.liquidity || current.volume || 0;
return currentScore > bestScore ? current : best;
}, null);
-
+
if (selectedToken) {
- console.log(`⚠️ ${symbol} fallback match: ${selectedToken.name} (${selectedToken.symbol})`);
+ console.log(
+ `⚠️ ${symbol} fallback match: ${selectedToken.name} (${selectedToken.symbol})`
+ );
}
}
-
+
if (selectedToken?.logo) {
return selectedToken.logo;
}
}
-
+
return null;
} catch (err) {
console.error(`❌ Failed to fetch logo for ${symbol}:`, err);
return null;
}
};
-
diff --git a/src/apps/key-wallet/utils/blockchain.ts b/src/apps/key-wallet/utils/blockchain.ts
index d9341baa..9fee661d 100644
--- a/src/apps/key-wallet/utils/blockchain.ts
+++ b/src/apps/key-wallet/utils/blockchain.ts
@@ -308,7 +308,7 @@ export const formatBalance = (
export const formatUsdValue = (value: number): string => {
if (value === 0) return '$0.00';
if (value < 0.01) return '<$0.01';
- return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
+ return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
export const shortenAddress = (address: string, chars: number = 4): string => {
diff --git a/src/apps/perps/App.css b/src/apps/perps/App.css
new file mode 100644
index 00000000..b9d355df
--- /dev/null
+++ b/src/apps/perps/App.css
@@ -0,0 +1,42 @@
+#root {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
diff --git a/src/apps/perps/components/AgentControls.tsx b/src/apps/perps/components/AgentControls.tsx
new file mode 100644
index 00000000..74627992
--- /dev/null
+++ b/src/apps/perps/components/AgentControls.tsx
@@ -0,0 +1,1248 @@
+import { useState, useEffect } from 'react';
+import { Button } from './ui/button';
+import { Card } from './ui/card';
+import { Badge } from './ui/badge';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from './ui/dialog';
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from './ui/collapsible';
+import { Input } from './ui/input';
+import { Label } from './ui/label';
+import {
+ Shield,
+ CheckCircle2,
+ AlertCircle,
+ Copy,
+ Download,
+ Upload,
+ Trash2,
+ Settings,
+ Lock,
+ ChevronDown,
+ Loader2,
+} from 'lucide-react';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuSeparator,
+} from './ui/dropdown-menu';
+import { toast } from 'sonner';
+import { createWalletClient, custom } from 'viem';
+import { arbitrum } from 'viem/chains';
+import useTransactionKit from '../../../hooks/useTransactionKit';
+import { useHyperliquid } from '../hooks/useHyperliquid';
+import { privateKeyToAccount } from 'viem/accounts';
+import type { Hex } from 'viem';
+import { generateAgentWallet } from '../lib/hyperliquid/signing';
+import {
+ buildApproveAgentAction,
+ getApproveAgentTypedData,
+} from '../lib/hyperliquid/signing';
+import { postExchange } from '../lib/hyperliquid/client';
+import { ValidationStatus } from './ValidationStatus';
+import { DepositModal } from './DepositModal';
+import type { UserState } from '../lib/hyperliquid/types';
+import { PinSetupModal } from './PinSetupModal';
+import { UnlockWalletModal } from './UnlockWalletModal';
+import { PrivateKeyModal } from './PrivateKeyModal';
+import { useIsMobile } from '../hooks/use-mobile';
+
+import {
+ storeAgentWallet,
+ updateAgentApproval,
+ updateBuilderApproval,
+ clearAgentWallet,
+ storeAgentWalletEncrypted,
+ unlockAgentWallet,
+ getAgentWallet,
+ getAgentAddress,
+ isAgentWalletEncrypted,
+ isImportedAccountEncrypted,
+ getImportedAccountAddress,
+ getImportedAccount,
+ clearImportedAccount,
+ unlockImportedAccount,
+ storeImportedAccountEncrypted,
+} from '../lib/hyperliquid/keystore';
+import { cn } from '../lib/utils';
+
+type AgentStatus = 'none' | 'created' | 'approved' | 'locked' | 'builder_approval_pending';
+
+interface AgentControlsProps {
+ onStatusChange?: () => void;
+ onAgentAddressChange?: (address: string | null) => void;
+ userState?: UserState;
+ ethPrice?: number;
+}
+
+// Add 'unlock' to revealMode type
+type RevealMode = 'copy' | 'download' | 'unlock';
+
+export function AgentControls({
+ onStatusChange,
+ onAgentAddressChange,
+ userState,
+ ethPrice,
+}: AgentControlsProps) {
+ const { walletProvider } = useTransactionKit();
+ const { address } = useHyperliquid();
+ // Removed useWalletClient from wagmi
+
+ // Calculate master balance for conditional logic
+ const masterBalance = userState?.marginSummary?.accountValue
+ ? parseFloat(userState.marginSummary.accountValue)
+ : 0;
+ const [agentStatus, setAgentStatus] = useState('none');
+ const [agentAddress, setAgentAddress] = useState('');
+ const [isCreating, setIsCreating] = useState(false);
+ const [isApproving, setIsApproving] = useState(false);
+ const [isLoadingAgent, setIsLoadingAgent] = useState(false);
+ const [agentPrivateKey, setAgentPrivateKey] = useState('');
+ const [showImportDialog, setShowImportDialog] = useState(false);
+ const [importPrivateKey, setImportPrivateKey] = useState('');
+ const [importAccountAddress, setImportAccountAddress] = useState('');
+ const [isRemoving, setIsRemoving] = useState(false);
+ const isMobile = useIsMobile();
+ const [isOpen, setIsOpen] = useState(true);
+
+ useEffect(() => {
+ if (isMobile) {
+ if (agentStatus === 'approved') {
+ setIsOpen(false);
+ } else {
+ setIsOpen(true);
+ }
+ } else {
+ setIsOpen(true);
+ }
+ }, [isMobile, agentStatus]);
+
+ const [validationStatus, setValidationStatus] = useState<
+ 'idle' | 'validating' | 'success' | 'error'
+ >('idle');
+ const [validationData, setValidationData] = useState<{
+ agentAddress?: string;
+ balance?: string;
+ openPositions?: number;
+ errorMessage?: string;
+ }>({});
+
+ // PIN & Encryption State
+ const [showPinSetup, setShowPinSetup] = useState(false);
+ const [showUnlockReveal, setShowUnlockReveal] = useState(false);
+ const [revealMode, setRevealMode] = useState<'unlock' | 'reveal'>('unlock'); // 'unlock' = just unlock, 'reveal' = show key
+ const [pendingImportData, setPendingImportData] = useState<{
+ address: string;
+ privateKey: Hex;
+ accountState?: any;
+ } | null>(null);
+
+ const [privateKeyModalState, setPrivateKeyModalState] = useState<{
+ isOpen: boolean;
+ address: string;
+ privateKey: string;
+ mode: 'created' | 'revealed';
+ }>({
+ isOpen: false,
+ address: '',
+ privateKey: '',
+ mode: 'created',
+ });
+
+ // Check status on mount / address change
+ useEffect(() => {
+ const checkStatus = async () => {
+ // Priority 1: Check for unlocked imported account (memory/cache)
+ // We check this FIRST so that if we just created/unlocked it, we don't force a re-unlock.
+ const imported = getImportedAccount();
+
+ if (imported) {
+ setAgentAddress(imported.accountAddress);
+ setAgentPrivateKey(imported.privateKey);
+ setAgentStatus('approved');
+ return;
+ }
+
+ // Priority 2: Check for GLOBAL imported account (locked)
+ const isEncrypted = isImportedAccountEncrypted();
+
+ if (isEncrypted) {
+ const addr = getImportedAccountAddress();
+ if (addr) {
+ setAgentAddress(addr);
+ setAgentStatus('locked');
+ // Auto-prompt check
+ setRevealMode('unlock');
+ setShowUnlockReveal(true);
+ return;
+ }
+ }
+
+ // Priority 3: Check for agent wallet (if connected wallet exists)
+ if (!address) {
+ setAgentStatus('none');
+ return;
+ }
+
+ // 3a. Try to get unlocked agent wallet
+ const wallet = await getAgentWallet(address);
+ if (wallet) {
+ setAgentAddress(wallet.address);
+ setAgentPrivateKey(wallet.privateKey);
+ if (wallet.approved) {
+ if (wallet.builderApproved) {
+ setAgentStatus('approved');
+ } else {
+ setAgentStatus('builder_approval_pending');
+ }
+ } else {
+ setAgentStatus('created');
+ }
+ return;
+ }
+
+ // 3b. Check if agent wallet is locked
+ if (isAgentWalletEncrypted(address)) {
+ const addr = getAgentAddress(address);
+ if (addr) {
+ setAgentAddress(addr);
+ setAgentStatus('locked');
+ setRevealMode('unlock');
+ setShowUnlockReveal(true);
+ }
+ return;
+ }
+
+ setAgentStatus('none');
+ };
+
+ checkStatus();
+ }, [address, validationStatus]); // Re-run if validation finishes (import) or address changes
+
+ // Notify parent of agent address changes
+ useEffect(() => {
+ if (onAgentAddressChange) {
+ onAgentAddressChange(agentAddress || null);
+ }
+ }, [agentAddress, onAgentAddressChange]);
+
+ // Auto-show unlock modal when wallet becomes locked
+ useEffect(() => {
+ if (agentStatus === 'locked' && !showUnlockReveal) {
+ setRevealMode('unlock');
+ setShowUnlockReveal(true);
+ }
+ }, [agentStatus, showUnlockReveal]);
+
+ const handleUnlockClick = () => {
+ setShowUnlockReveal(true);
+ setRevealMode('copy'); // Default mode, but actually we just want to unlock session
+ // We need to distinguish between "Unlock Session" and "Reveal Key".
+ // For now, let's just use the same modal.
+ // If we rename revealMode to 'unlock' | 'copy' | 'download'?
+ };
+
+ // Modify handleUnlockForReveal to handle simple unlock
+ const handleUnlockForReveal = async (pin: string): Promise => {
+ try {
+ // Priority 1: Try unlocking imported account
+ // Priority 1: Try unlocking imported account
+ if (isImportedAccountEncrypted()) {
+ const unlocked = await unlockImportedAccount(pin);
+ if (unlocked) {
+ setAgentAddress(unlocked.accountAddress);
+ setAgentPrivateKey(unlocked.privateKey);
+ setAgentStatus('approved');
+ setShowUnlockReveal(false);
+
+ // Show Key in Modal ONLY if revealed
+ if (revealMode === 'reveal') {
+ setPrivateKeyModalState({
+ isOpen: true,
+ address: unlocked.accountAddress,
+ privateKey: unlocked.privateKey,
+ mode: 'revealed',
+ });
+ } else {
+ toast.success('Wallet unlocked');
+ }
+
+ // Trigger data refresh on parent
+ if (onStatusChange) {
+ onStatusChange();
+ }
+
+ return true;
+ }
+ }
+
+ // Priority 2: Try unlocking agent wallet (if connected)
+ if (!address) return false;
+ const unlocked = await unlockAgentWallet(address, pin);
+ if (unlocked) {
+ setAgentPrivateKey(unlocked.privateKey);
+ if (unlocked.approved) {
+ if (unlocked.builderApproved) {
+ setAgentStatus('approved');
+ } else {
+ setAgentStatus('builder_approval_pending');
+ }
+ } else {
+ setAgentStatus('created');
+ }
+ setShowUnlockReveal(false);
+
+ // Show Key in Modal ONLY if revealed
+ if (revealMode === 'reveal') {
+ setPrivateKeyModalState({
+ isOpen: true,
+ address: unlocked.address,
+ privateKey: unlocked.privateKey,
+ mode: 'revealed',
+ });
+ } else {
+ toast.success('Wallet unlocked'); // Silent unlock
+ }
+
+ // Trigger data refresh on parent
+ if (onStatusChange) {
+ onStatusChange();
+ }
+
+ return true;
+ }
+ return false;
+ } catch (e) {
+ throw e;
+ }
+ };
+
+ const handleCreateAgentClick = () => {
+ if (!address) {
+ toast.error('Please connect your wallet first');
+ return;
+ }
+
+ // Proceed directly to setup - if UI shows "Create New", we assume we can create new.
+ // Any existing data will be overwritten.
+ setPendingImportData(null);
+ setShowPinSetup(true);
+ };
+
+ const handleAgentCreationWithPin = async (pin: string) => {
+ setShowPinSetup(false);
+ setIsCreating(true);
+ try {
+ if (pendingImportData) {
+ // Encrypt IMPORTED wallet as GLOBAL account
+ await storeImportedAccountEncrypted(
+ importAccountAddress.trim() || pendingImportData.address,
+ pendingImportData.privateKey,
+ pin
+ );
+
+ // Update local state immediately
+ setAgentAddress(
+ importAccountAddress.trim() || pendingImportData.address
+ );
+ setAgentPrivateKey(pendingImportData.privateKey);
+ setAgentStatus('approved');
+
+ // Restore Validation Success Logic (Deferred from Import)
+ // Restore Validation Success Logic (Deferred from Import)
+ // Data is already set in handleImportAgent
+
+ toast.success('✅ Account imported!', {
+ description: 'Agent wallet secured successfully.',
+ duration: 5000,
+ });
+
+ // Clear validation status now that flow is complete
+ setValidationStatus('idle');
+ } else {
+ // Generate NEW wallet
+
+ // Safety check: Verify address still exists before proceeding
+ if (!address) {
+ toast.error(
+ 'Wallet connection lost. Please reconnect and try again.'
+ );
+ setIsCreating(false);
+ return;
+ }
+
+ const wallet = generateAgentWallet();
+
+ await storeAgentWalletEncrypted(
+ address,
+ wallet.address,
+ wallet.privateKey,
+ pin,
+ false
+ );
+
+ setAgentAddress(wallet.address);
+ setAgentPrivateKey(wallet.privateKey); // Keep in memory for this session
+ setAgentStatus('created');
+
+ // Show Success Modal
+ setPrivateKeyModalState({
+ isOpen: true,
+ address: wallet.address,
+ privateKey: wallet.privateKey,
+ mode: 'created',
+ });
+ }
+
+ // Trigger data refresh on parent
+ if (onStatusChange) {
+ onStatusChange();
+ }
+ } catch (error: any) {
+ console.error('Agent creation/import error:', error);
+ toast.error('Failed to secure agent wallet');
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+
+ const [isApprovingBuilder, setIsApprovingBuilder] = useState(false);
+
+ const handleApproveBuilder = async () => {
+ if (!address || !walletProvider) {
+ toast.error('Please connect your wallet');
+ return;
+ }
+ setIsApprovingBuilder(true);
+ try {
+ console.log('Approving Builder...');
+ const { BUILDER_ADDRESS, BUILDER_FEE_APPROVAL } = await import(
+ '../lib/hyperliquid/builder'
+ );
+
+ const { buildApproveBuilderFeeAction, signApproveBuilderFeeAction } =
+ await import('../lib/hyperliquid/signing');
+ const { postExchange } = await import('../lib/hyperliquid/client');
+
+ // 1. Get account
+ let accountToUse = address as Hex;
+ if (walletProvider && 'request' in walletProvider) {
+ // Logic to ensure account (omitted for brevity, relying on address/provider match)
+ }
+
+ const action = buildApproveBuilderFeeAction({
+ maxFeeRate: BUILDER_FEE_APPROVAL,
+ builderAddress: BUILDER_ADDRESS,
+ nonce: Date.now(),
+ });
+
+ // 2. Sign
+ const signature = await signApproveBuilderFeeAction(
+ walletProvider as any,
+ action
+ );
+
+ const apiAction = {
+ ...action,
+ builder: action.builder.toLowerCase(),
+ };
+
+ const payload = {
+ action: apiAction,
+ nonce: action.nonce,
+ signature,
+ vaultAddress: null,
+ };
+
+ // 3. Post
+ const response = await postExchange(payload);
+ if (response.status === 'ok') {
+ toast.success('PillarX Approved!');
+
+ // Update local state to fully approved
+ updateBuilderApproval(address, true);
+ setAgentStatus('approved');
+
+ if (onStatusChange) {
+ onStatusChange();
+ }
+
+ } else {
+ throw new Error(response.response?.data?.toString() || 'Failed');
+ }
+
+ } catch (error: any) {
+ console.error('Failed to approve PillarX:', error);
+ toast.error(error.message || 'Failed to approve PillarX');
+ } finally {
+ setIsApprovingBuilder(false);
+ }
+ };
+
+ const handleApproveAgent = async () => {
+ if (!address || !walletProvider) {
+ toast.error('Please connect your wallet');
+ return;
+ }
+
+ const agent = await getAgentWallet(address);
+ if (!agent) {
+ toast.error('No agent wallet found');
+ return;
+ }
+
+ if (agent.approved) {
+ // Check builder status
+ if (agent.builderApproved) {
+ toast.success('Agent is already approved');
+ setAgentStatus('approved');
+ return;
+ } else {
+ // Transition to builder pending
+ setAgentStatus('builder_approval_pending');
+ return;
+ }
+ }
+
+ setIsApproving(true);
+ try {
+ let accountToUse = address as Hex;
+
+ // Probe walletProvider structure
+ if (walletProvider) {
+ // Check if we need to request accounts
+ if ('request' in walletProvider) {
+ try {
+ // @ts-ignore
+ const accounts = await walletProvider.request({
+ method: 'eth_accounts',
+ });
+ if (accounts && Array.isArray(accounts) && accounts.length > 0) {
+ // Use the account from the provider to ensure case match
+ accountToUse = accounts[0];
+ } else {
+ console.warn(
+ 'No accounts found from provider. Requesting access...'
+ );
+ // @ts-ignore
+ const requested = await walletProvider.request({
+ method: 'eth_requestAccounts',
+ });
+ if (
+ requested &&
+ Array.isArray(requested) &&
+ requested.length > 0
+ ) {
+ accountToUse = requested[0];
+ }
+ }
+ } catch (e) {
+ console.error('Error checking accounts:', e);
+ }
+
+ // Check chain ID and switch if necessary
+ try {
+ // @ts-ignore
+ const chainId = await walletProvider.request({
+ method: 'eth_chainId',
+ });
+
+ const targetChainId = '0xa4b1'; // Arbitrum One
+
+ if (chainId !== targetChainId) {
+ try {
+ // @ts-ignore
+ await walletProvider.request({
+ method: 'wallet_switchEthereumChain',
+ params: [{ chainId: targetChainId }],
+ });
+ } catch (switchError: any) {
+ // This error code indicates that the chain has not been added to MetaMask.
+ if (switchError.code === 4902) {
+ // @ts-ignore
+ await walletProvider.request({
+ method: 'wallet_addEthereumChain',
+ params: [
+ {
+ chainId: targetChainId,
+ chainName: 'Arbitrum One',
+ rpcUrls: ['https://arb1.arbitrum.io/rpc'],
+ nativeCurrency: {
+ name: 'Ether',
+ symbol: 'ETH',
+ decimals: 18,
+ },
+ blockExplorerUrls: ['https://arbiscan.io'],
+ },
+ ],
+ });
+ } else {
+ throw switchError;
+ }
+ }
+ }
+ } catch (e) {
+ console.error('Error switching chain:', e);
+ toast.error(
+ 'Failed to switch network. Please switch to Arbitrum manually.'
+ );
+ setIsApproving(false);
+ return;
+ }
+ }
+ }
+
+ const actionConfig = buildApproveAgentAction({
+ agentAddress: agent.address,
+ nonce: Date.now(),
+ });
+
+ // Get EIP-712 typed data structures
+ const { domain, types, primaryType, message } = getApproveAgentTypedData(
+ actionConfig.hyperliquidChain,
+ actionConfig.signatureChainId,
+ actionConfig.agentAddress,
+ actionConfig.agentName,
+ actionConfig.nonce
+ );
+
+ // Note: walletProvider is already a viem WalletClient in this context
+ // We cast it to any/WalletClient to access signTypedData
+ const signature = await (walletProvider as any).signTypedData({
+ account: accountToUse,
+ domain,
+ types,
+ primaryType,
+ message,
+ });
+
+ // Ensure agent address is lowercase for API
+ // And include signatureChainId as it is required by the API
+ const apiAction = {
+ ...actionConfig,
+ agentAddress: actionConfig.agentAddress.toLowerCase(),
+ };
+
+ const payload = {
+ action: apiAction,
+ nonce: actionConfig.nonce,
+ signature: {
+ r: signature.slice(0, 66),
+ s: '0x' + signature.slice(66, 130),
+ v: parseInt(signature.slice(130, 132), 16),
+ },
+ vaultAddress: null,
+ };
+
+ const response = await postExchange(payload);
+
+ if (response.status === 'ok') {
+ // Store approval status locally WITHOUT overwriting/touching the keys
+ updateAgentApproval(address, true);
+ // Transition to Builder Approval pending instead of full success
+ setAgentStatus('builder_approval_pending');
+ toast.success('Agent approved! Now verify PillarX.');
+
+ if (onStatusChange) {
+ onStatusChange();
+ }
+ } else {
+ throw new Error(
+ response.response?.data?.toString() || 'Approval failed'
+ );
+ }
+ } catch (error: any) {
+ console.error('Approval error:', error);
+ toast.error('Failed to approve agent', {
+ description: error.message,
+ });
+ } finally {
+ setIsApproving(false);
+ }
+ };
+
+ const copyAddress = () => {
+ if (agentAddress) {
+ navigator.clipboard.writeText(agentAddress);
+ toast.success('Agent address copied!');
+ }
+ };
+
+ const copyPrivateKey = () => {
+ if (agentPrivateKey) {
+ navigator.clipboard.writeText(agentPrivateKey);
+ toast.success('Private key copied!');
+ } else {
+ setRevealMode('copy');
+ setShowUnlockReveal(true);
+ }
+ };
+
+ const downloadPrivateKey = () => {
+ if (agentPrivateKey) {
+ downloadKeyFile(agentAddress, agentPrivateKey);
+ } else {
+ setRevealMode('download');
+ setShowUnlockReveal(true);
+ }
+ };
+
+ const downloadKeyFile = (addr: string, key: string) => {
+ const data = JSON.stringify(
+ {
+ address: addr,
+ privateKey: key,
+ createdAt: new Date().toISOString(),
+ note: 'KEEP THIS SAFE. DO NOT SHARE.',
+ },
+ null,
+ 2
+ );
+
+ const blob = new Blob([data], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `agent-wallet-${addr.slice(0, 8)}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ toast.success('Private key downloaded!');
+ };
+
+ const handleRemoveAccount = async () => {
+ try {
+ // Clear data
+ clearImportedAccount();
+
+ // Reset all local state
+ setAgentAddress('');
+ setAgentPrivateKey('');
+ setAgentStatus('none');
+
+ // Close any open modals
+ setShowPinSetup(false);
+ setShowUnlockReveal(false);
+ setRevealMode('unlock'); // Reset default
+
+ // Reset validation state
+ setValidationData({});
+ // Note: Setting validationStatus to 'idle' will trigger the useEffect to run checkStatus()
+ // This is desired behavior - it will re-check and find 'none' (or fallback to native agent)
+ setValidationStatus('idle');
+
+ toast.success('Imported account removed');
+
+ // Trigger data refresh to clear displayed data
+ if (onStatusChange) {
+ onStatusChange();
+ }
+ } catch (error: any) {
+ console.error('[AgentControls] Error removing account:', error);
+ toast.error('Failed to remove account');
+ }
+ };
+
+ const handleImportAgent = async () => {
+ if (!importAccountAddress.trim()) {
+ toast.error('Please enter an account address');
+ return;
+ }
+
+ if (!importPrivateKey.trim()) {
+ toast.error('Please enter a private key');
+ return;
+ }
+
+ // Show loading toast and set validating status
+ const loadingToast = toast.loading('Validating agent credentials...');
+ setValidationStatus('validating');
+ setValidationData({});
+
+ try {
+ // Validate account address format
+ if (!importAccountAddress.trim().match(/^0x[a-fA-F0-9]{40}$/)) {
+ toast.dismiss(loadingToast);
+ toast.error('Invalid account address format');
+ return;
+ }
+
+ // Validate and derive address from private key
+ const formattedKey = importPrivateKey.trim().startsWith('0x')
+ ? (importPrivateKey.trim() as Hex)
+ : (`0x${importPrivateKey.trim()}` as Hex);
+
+ const account = privateKeyToAccount(formattedKey);
+
+ toast.loading('Checking Hyperliquid connection...', { id: loadingToast });
+
+ // Validate that the ACCOUNT ADDRESS exists on Hyperliquid (not the agent)
+ try {
+ const { getUserState } = await import('../lib/hyperliquid/client');
+ const accountState = await getUserState(importAccountAddress.trim());
+
+ if (!accountState) {
+ toast.dismiss(loadingToast);
+ toast.error('Account address not found on Hyperliquid', {
+ description:
+ 'This address has no Hyperliquid account. Please use a valid account address.',
+ });
+ return;
+ }
+
+ toast.loading('Saving credentials...', { id: loadingToast });
+
+ // INSTEAD of storing immediately, we now PROMPT FOR PIN
+ toast.dismiss(loadingToast);
+ setShowImportDialog(false);
+
+ //Set pending data
+ //Set pending data
+ setPendingImportData({
+ address: account.address,
+ privateKey: formattedKey,
+ accountState: accountState,
+ });
+
+ // Show Success status immediately - BEFORE Pin Setup
+ const openPositions =
+ accountState.assetPositions?.filter(
+ (p: any) => parseFloat(p.position.szi) !== 0
+ ).length || 0;
+
+ setValidationStatus('success');
+ setValidationData({
+ agentAddress: importAccountAddress.trim() || account.address,
+ balance: parseFloat(
+ accountState.marginSummary?.totalRawUsd || '0'
+ ).toFixed(2),
+ openPositions,
+ });
+
+ // Open PIN setup
+ setShowPinSetup(true);
+
+ // Notify parent validation passed (optional, keeps UI fresh)
+ if (onStatusChange) {
+ onStatusChange();
+ }
+ } catch (validationError: any) {
+ toast.dismiss(loadingToast);
+ console.error('[Import Agent] Validation error:', validationError);
+
+ // Set error status
+ setValidationStatus('error');
+ setValidationData({
+ errorMessage:
+ validationError.message ||
+ 'Could not fetch agent data. Please check your internet connection and try again.',
+ });
+
+ toast.error('❌ Failed to connect to Hyperliquid', {
+ description:
+ validationError.message ||
+ 'Could not fetch agent data. Please check your internet connection and try again.',
+ duration: 5000,
+ });
+ return;
+ }
+ } catch (error: any) {
+ toast.dismiss(loadingToast);
+ console.error('Import error:', error);
+ toast.error('Invalid private key', {
+ description: error.message || 'Please check the format and try again',
+ });
+ }
+ };
+
+ const handleRemoveAgent = async () => {
+ if (!address) {
+ toast.error('Please connect your wallet first');
+ return;
+ }
+
+ setIsRemoving(true);
+ try {
+ // Clear from remote first, then local
+ await clearAgentWallet(address);
+
+ // Verify deletion
+ const stillExists = await getAgentWallet(address);
+ if (stillExists) {
+ throw new Error(
+ 'Agent wallet still exists after deletion. Please try again.'
+ );
+ }
+
+ // Update UI
+ setAgentAddress('');
+ setAgentPrivateKey('');
+ setAgentStatus('none');
+ toast.success('Agent wallet removed from all storage');
+ onStatusChange?.();
+ } catch (error: any) {
+ console.error('Remove agent error:', error);
+ toast.error(error.message || 'Failed to remove agent wallet');
+ } finally {
+ setIsRemoving(false);
+ }
+ };
+
+ const statusConfig = {
+ locked: {
+ icon: Lock,
+ label: 'Active (Locked)',
+ color: 'text-orange-500',
+ bgColor: 'bg-orange-500/10 border-orange-500/30',
+ },
+ none: {
+ icon: AlertCircle,
+ label: 'No Agent',
+ color: 'text-muted-foreground',
+ bgColor: 'bg-muted',
+ },
+ created: {
+ icon: AlertCircle,
+ label: 'Not Approved',
+ color: 'text-warning',
+ bgColor: 'bg-warning/10 border-warning/30',
+ },
+ 'builder_approval_pending': {
+ icon: AlertCircle,
+ label: 'Setup Incomplete',
+ color: 'text-orange-500',
+ bgColor: 'bg-orange-500/10 border-orange-500/30',
+ },
+ approved: {
+ icon: CheckCircle2,
+ label: 'Active',
+ color: 'text-success',
+ bgColor: 'bg-success/10 border-success/30',
+ },
+ };
+
+ const config = statusConfig[agentStatus];
+ const Icon = config.icon;
+
+ // Add Lock icon import if not present (Wait, imports are at top)
+ // I need to add Lock to imports at top manually in first block?
+ // Or assume lucide-react has it (it does).
+ // Let's add the button for Locked state.
+
+ return (
+
+
+
+
+
+
+ Perps Account:
+
+
+ {config.label}
+
+
+ {isMobile && (
+
+
+
+ )}
+
+
+
+ {/* Validation Status Display */}
+ {validationStatus !== 'idle' && (
+
+
+
+ )}
+
+ {agentStatus === 'none' && (
+ <>
+ {!address ? null : isLoadingAgent ? (
+
+ Loading agent wallet...
+
+ ) : (
+ <>
+
+
+
+
+
+ 💡 Create a new Hyperliquid agent wallet or import your
+ existing one
+
+ >
+ )}
+ >
+ )}
+
+
+
+ {agentStatus === 'locked' && (
+
+ )}
+
+ {agentStatus === 'created' && (
+
+ {masterBalance < 10 && userState ? (
+
+ Deposit $10 USDC to Trade
+
+ }
+ />
+ ) : (
+
+ )}
+
+
+
+
+ )}
+
+ {agentStatus === 'builder_approval_pending' && (
+
+
+
+
+
+
+
+ )}
+
+ {agentStatus === 'approved' && (
+
+
+
+
+
+
+ Activated Account
+
+
+
+
+
+
+
+
+
+ {
+ setRevealMode('reveal');
+ setShowUnlockReveal(true);
+ }}
+ >
+ Reveal Private Key
+
+
+
+
+ Remove Account
+
+
+
+
+
+
+
Address:
+
+
+ {agentAddress}
+
+
+
+
+
+
+ )}
+
+ setShowPinSetup(false)}
+ />
+
+ setShowUnlockReveal(false)}
+ />
+
+
+ setPrivateKeyModalState({
+ ...privateKeyModalState,
+ isOpen: false,
+ })
+ }
+ />
+
+
+
+ );
+}
diff --git a/src/apps/perps/components/AssetSelector.tsx b/src/apps/perps/components/AssetSelector.tsx
new file mode 100644
index 00000000..0eac5172
--- /dev/null
+++ b/src/apps/perps/components/AssetSelector.tsx
@@ -0,0 +1,187 @@
+import { useState, useMemo } from 'react';
+import { Search, ArrowUpDown } from 'lucide-react';
+import { Input } from './ui/input';
+import { Card } from './ui/card';
+import { ScrollArea } from './ui/scroll-area';
+import { Button } from './ui/button';
+import { AssetInfo, EnhancedAsset } from '../lib/hyperliquid/types';
+import { Skeleton } from './ui/skeleton';
+import { TokenIcon } from './TokenIcon';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from './ui/dropdown-menu';
+
+interface AssetSelectorProps {
+ selectedSymbol: string | null;
+ onSelect: (symbol: string, asset: AssetInfo) => void;
+ assets: EnhancedAsset[];
+}
+
+type SortBy = 'price' | 'volume' | 'change';
+
+export function AssetSelector({
+ selectedSymbol,
+ onSelect,
+ assets,
+}: AssetSelectorProps) {
+ const [search, setSearch] = useState('');
+ const [sortBy, setSortBy] = useState('volume');
+
+ // Loading state is now determined by whether assets array is empty
+ const isLoading = assets.length === 0;
+
+ const filteredAndSortedAssets = useMemo(() => {
+ let filtered = assets;
+
+ // Filter by search
+ if (search) {
+ const searchLower = search.toLowerCase();
+ filtered = assets.filter((asset) =>
+ asset.symbol.toLowerCase().includes(searchLower)
+ );
+ }
+
+ // Sort
+ return [...filtered].sort((a, b) => {
+ switch (sortBy) {
+ case 'price':
+ return b.price - a.price;
+ case 'volume':
+ return b.volume - a.volume;
+ case 'change':
+ return b.priceChangePercent - a.priceChangePercent;
+ default:
+ return 0;
+ }
+ });
+ }, [assets, search, sortBy]);
+
+ const formatPrice = (price: number): string => {
+ if (price >= 1000) {
+ return price.toLocaleString('en-US', { maximumFractionDigits: 0 });
+ }
+ return price.toFixed(2);
+ };
+
+ const formatVolume = (volume: number): string => {
+ if (volume >= 1e9) {
+ return `$${(volume / 1e9).toFixed(1)}B`;
+ }
+ if (volume >= 1e6) {
+ return `$${(volume / 1e6).toFixed(1)}M`;
+ }
+ if (volume >= 1e3) {
+ return `$${(volume / 1e3).toFixed(1)}K`;
+ }
+ return `$${volume.toFixed(0)}`;
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ setSearch(e.target.value)}
+ className="pl-10"
+ />
+
+
+ {/* Sort Dropdown */}
+
+
+
+
+
+ setSortBy('price')}>
+ Sort by Price
+
+ setSortBy('volume')}>
+ Sort by Volume
+
+ setSortBy('change')}>
+ Sort by Change %
+
+
+
+
+
+
+
+ {filteredAndSortedAssets.map((asset) => {
+ const isPositive = asset.priceChangePercent >= 0;
+
+ return (
+
+ );
+ })}
+ {filteredAndSortedAssets.length === 0 && (
+
+ No assets found
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/apps/perps/components/BalanceCard.tsx b/src/apps/perps/components/BalanceCard.tsx
new file mode 100644
index 00000000..6e05f37f
--- /dev/null
+++ b/src/apps/perps/components/BalanceCard.tsx
@@ -0,0 +1,151 @@
+import { Card } from './ui/card';
+import { RefreshCw, Plus, Minus } from 'lucide-react';
+import { useIsMobile } from '../hooks/use-mobile';
+import { Button } from './ui/button';
+import { DepositModal } from './DepositModal';
+import { WithdrawModal } from './WithdrawModal';
+import type { UserState } from '../lib/hyperliquid/types';
+import { useMemo } from 'react';
+
+interface BalanceCardProps {
+ userState: UserState;
+ isLoading: boolean;
+ masterAddress: string;
+ walletClient: any;
+ onRefresh?: () => void;
+ isImported?: boolean;
+ ethPrice?: number;
+}
+
+export function BalanceCard({
+ userState,
+ isLoading,
+ masterAddress,
+ walletClient,
+ onRefresh,
+ isImported,
+ ethPrice,
+}: BalanceCardProps) {
+ const accountEquity = parseFloat(
+ userState.marginSummary?.accountValue || '0'
+ );
+
+ // Calculate total PnL from all positions
+ const totalPnl = useMemo(() => {
+ if (!userState.assetPositions) return 0;
+ return userState.assetPositions.reduce((sum, pos) => {
+ return sum + parseFloat(pos.position.unrealizedPnl || '0');
+ }, 0);
+ }, [userState.assetPositions]);
+
+ const pnlPercent =
+ accountEquity > 0
+ ? ((totalPnl / (accountEquity - totalPnl)) * 100).toFixed(2)
+ : '0.00';
+
+ const isPnlPositive = totalPnl >= 0;
+
+ const isMobile = useIsMobile();
+
+ return (
+
+
+
+ Perps Balance
+
+
+
+
+
+ ) : undefined
+ }
+ />
+
+
+
+ ) : undefined
+ }
+ />
+ {onRefresh && (
+
+ )}
+
+
+
+
+ {/* Main Balance */}
+
+
+ $
+ {accountEquity.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}
+
+
+
+ {/* PnL Display */}
+
+
+ {isPnlPositive ? '+' : ''}$
+ {totalPnl.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}
+
+
+ {isPnlPositive ? '+' : ''}
+ {pnlPercent}% {isPnlPositive ? '↑' : '↓'}
+
+
+
+ {/* Note about PnL calculation */}
+
+ Showing total unrealized PnL from open positions
+
+
+
+ );
+}
diff --git a/src/apps/perps/components/CompactHeader.tsx b/src/apps/perps/components/CompactHeader.tsx
new file mode 100644
index 00000000..4172538e
--- /dev/null
+++ b/src/apps/perps/components/CompactHeader.tsx
@@ -0,0 +1,73 @@
+import { Card } from './ui/card';
+import { Button } from './ui/button';
+import { Settings, CheckCircle2, AlertCircle } from 'lucide-react';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuSeparator,
+} from './ui/dropdown-menu';
+
+interface CompactHeaderProps {
+ accountAddress: string;
+ balance: string;
+ onViewKey: () => void;
+ onRemove: () => void;
+ isConnected: boolean;
+}
+
+export function CompactHeader({
+ accountAddress,
+ balance,
+ onViewKey,
+ onRemove,
+ isConnected,
+}: CompactHeaderProps) {
+ return (
+
+
+
+ {isConnected ? (
+ <>
+
+
+ {accountAddress.slice(0, 6)}...{accountAddress.slice(-4)}
+
+ >
+ ) : (
+ <>
+
+
+ Not connected
+
+ >
+ )}
+
+
+
+ {isConnected && (
+ ${balance}
+ )}
+
+
+
+
+
+
+
+ View Private Key
+
+
+
+ Remove Account
+
+
+
+
+
+
+ );
+}
diff --git a/src/apps/perps/components/ConnectButton.tsx b/src/apps/perps/components/ConnectButton.tsx
new file mode 100644
index 00000000..b2586d1f
--- /dev/null
+++ b/src/apps/perps/components/ConnectButton.tsx
@@ -0,0 +1,63 @@
+import { useAccount, useConnect, useDisconnect } from 'wagmi';
+import { Button } from './ui/button';
+import { Wallet, LogOut, AlertTriangle } from 'lucide-react';
+import { Badge } from './ui/badge';
+
+export function ConnectButton() {
+ const { address, isConnected, chain } = useAccount();
+ const { connect, connectors, isPending } = useConnect();
+ const { disconnect } = useDisconnect();
+
+ if (isConnected && address) {
+ const isArbitrum = chain?.id === 42161;
+
+ return (
+
+
+
+ {address.slice(0, 6)}...{address.slice(-4)}
+
+
+
+ {chain?.name || 'Unknown Network'}
+
+ {!isArbitrum && (
+
+
+ Switch to Arbitrum
+
+ )}
+
+
+
+
+ );
+ }
+
+ return (
+
+ {connectors.map((connector) => (
+
+ ))}
+
+ );
+}
diff --git a/src/apps/perps/components/CopyTile.tsx b/src/apps/perps/components/CopyTile.tsx
new file mode 100644
index 00000000..b845c468
--- /dev/null
+++ b/src/apps/perps/components/CopyTile.tsx
@@ -0,0 +1,137 @@
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from './ui/card';
+import { Badge } from './ui/badge';
+import { Button } from './ui/button';
+import { TrendingUp, TrendingDown, Target, Shield, Trophy } from 'lucide-react';
+import type { CopyTile as CopyTileType } from '../lib/hyperliquid/types';
+import { getEntryPrice } from '../lib/hyperliquid/math';
+
+interface CopyTileProps {
+ tile: CopyTileType;
+ onExecute: () => void;
+ isExecuting: boolean;
+ disabled: boolean;
+}
+
+export function CopyTile({
+ tile,
+ onExecute,
+ isExecuting,
+ disabled,
+}: CopyTileProps) {
+ const entryPrice = getEntryPrice(tile.entry);
+ const isLong = tile.side === 'long';
+
+ const formatPrice = (price: number | number[]) => {
+ if (Array.isArray(price)) {
+ return `$${price[0]} - $${price[1]}`;
+ }
+ return `$${price}`;
+ };
+
+ const formatTakeProfits = () => {
+ if (typeof tile.takeProfits === 'number') {
+ return `$${tile.takeProfits}`;
+ }
+ if (tile.takeProfits.length === 2 && !Array.isArray(tile.takeProfits[0])) {
+ return `$${tile.takeProfits[0]} - $${tile.takeProfits[1]}`;
+ }
+ return tile.takeProfits.map((tp) => `$${tp}`).join(', ');
+ };
+
+ return (
+
+
+
+
+
+ {tile.symbol}
+
+ {isLong ? (
+ <>
+
+ LONG
+ >
+ ) : (
+ <>
+
+ SHORT
+ >
+ )}
+
+
+
+ Copy Trade • $10 Notional • 5× Leverage
+
+
+
+
+
+
+
+
+
+
+
+
Entry
+
+ {formatPrice(tile.entry)}
+
+
+
+
+
+
+
+
+
+
Stop Loss
+
+ ${tile.stopLoss}
+
+
+
+
+
+
+
+
+
+
Take Profits
+
+ {formatTakeProfits()}
+
+
+
+
+
+
+
+ {disabled && (
+
+ Connect wallet and setup Hyperliquid to trade
+
+ )}
+
+
+ );
+}
diff --git a/src/apps/perps/components/DepositModal.tsx b/src/apps/perps/components/DepositModal.tsx
new file mode 100644
index 00000000..24025601
--- /dev/null
+++ b/src/apps/perps/components/DepositModal.tsx
@@ -0,0 +1,433 @@
+import { useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '../components/ui/dialog';
+import { Button } from '../components/ui/button';
+import { Input } from '../components/ui/input';
+import { Label } from '../components/ui/label';
+import { ArrowDownUp, ExternalLink } from 'lucide-react';
+import { toast } from 'sonner';
+import { ethers } from 'ethers';
+import { checkUSDCBalance } from '../lib/hyperliquid/bridge';
+import useTransactionKit from '../../../hooks/useTransactionKit';
+import { useHyperliquid } from '../hooks/useHyperliquid';
+import { erc20Abi, parseUnits } from 'viem';
+import { cn } from '../lib/utils';
+
+// Contract addresses
+const USDC_CONTRACT_ADDRESS =
+ '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as const;
+const BRIDGE_CONTRACT_ADDRESS =
+ '0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7' as const;
+
+interface DepositModalProps {
+ userState: any;
+ targetAddress?: string;
+ trigger?: React.ReactNode;
+ disabled?: boolean;
+ ethPrice?: number;
+}
+
+export function DepositModal({
+ userState,
+ targetAddress,
+ trigger,
+ disabled,
+ ethPrice,
+}: DepositModalProps) {
+ const [open, setOpen] = useState(false);
+ const [amount, setAmount] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [arbitrumBalance, setArbitrumBalance] = useState(null);
+ const [arbitrumEthBalance, setArbitrumEthBalance] = useState(null);
+ const [txHash, setTxHash] = useState(null);
+
+ const { kit, walletProvider } = useTransactionKit();
+ const { address, walletClient } = useHyperliquid();
+ const [isWrongNetwork, setIsWrongNetwork] = useState(false);
+
+ const isAddressMatch =
+ !targetAddress ||
+ !address ||
+ targetAddress.toLowerCase() === address.toLowerCase();
+
+ const checkNetwork = async () => {
+ if (walletProvider && 'request' in walletProvider) {
+ try {
+ // @ts-ignore
+ const chainId = await walletProvider.request({ method: 'eth_chainId' });
+ // Arbitrum One is 0xa4b1 (42161)
+ setIsWrongNetwork(chainId !== '0xa4b1');
+ } catch (e) {
+ console.warn('Failed to check network', e);
+ }
+ }
+ };
+ // ... checkNetwork ...
+
+ const switchToArbitrum = async () => {
+ if (!walletProvider || !('request' in walletProvider)) return;
+
+ try {
+ // @ts-ignore
+ await walletProvider.request({
+ method: 'wallet_switchEthereumChain',
+ params: [{ chainId: '0xa4b1' }],
+ });
+ setIsWrongNetwork(false);
+ } catch (switchError: any) {
+ // This error code indicates that the chain has not been added to MetaMask.
+ if (switchError.code === 4902) {
+ try {
+ // @ts-ignore
+ await walletProvider.request({
+ method: 'wallet_addEthereumChain',
+ params: [
+ {
+ chainId: '0xa4b1',
+ chainName: 'Arbitrum One',
+ rpcUrls: ['https://arb1.arbitrum.io/rpc'],
+ nativeCurrency: {
+ name: 'Ether',
+ symbol: 'ETH',
+ decimals: 18,
+ },
+ blockExplorerUrls: ['https://arbiscan.io'],
+ },
+ ],
+ });
+ setIsWrongNetwork(false);
+ } catch (addError) {
+ console.error('Failed to add Arbitrum network', addError);
+ }
+ } else {
+ console.error('Failed to switch to Arbitrum', switchError);
+ }
+ }
+ };
+
+ const fetchArbitrumBalance = async () => {
+ if (!address || !kit) return;
+ try {
+ if (!walletProvider) return;
+ const provider = new ethers.providers.Web3Provider(walletProvider as any);
+
+ const balance = await checkUSDCBalance(address, provider);
+ setArbitrumBalance(balance);
+
+ const ethBal = await provider.getBalance(address);
+ setArbitrumEthBalance(ethers.utils.formatEther(ethBal));
+ } catch (error) {
+ console.warn('Failed to fetch Arbitrum balance', error);
+ }
+ };
+
+ const handleOpenChange = (newOpen: boolean) => {
+ setOpen(newOpen);
+ if (newOpen) {
+ checkNetwork();
+ fetchArbitrumBalance();
+ setTxHash(null);
+ }
+ };
+
+ // Check network periodically or on provider change
+ useEffect(() => {
+ if (open) {
+ checkNetwork();
+ if (!isWrongNetwork) {
+ fetchArbitrumBalance();
+ }
+ }
+ }, [open, walletProvider, isWrongNetwork]);
+
+ const handleMaxClick = () => {
+ if (arbitrumBalance) {
+ setAmount(arbitrumBalance);
+ }
+ };
+
+ const handleDeposit = async () => {
+ if (isWrongNetwork) {
+ await switchToArbitrum();
+ return;
+ }
+
+ if (!amount || parseFloat(amount) <= 0) {
+ toast.error('Invalid Amount', {
+ description: 'Please enter a valid amount',
+ });
+ return;
+ }
+ // ... existing validation ...
+ // Check min deposit of 5 USDC for ALL deposits
+ if (parseFloat(amount) < 5) {
+ toast.error('Amount Too Low', {
+ description: 'Minimum deposit is 5 USDC',
+ });
+ return;
+ }
+
+ if (!address || !walletClient) {
+ toast.error('Wallet Not Connected', {
+ description: 'Please connect your wallet',
+ });
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ // Re-check network just in case
+ if (walletProvider && 'request' in walletProvider) {
+ // @ts-ignore
+ const chainId = await walletProvider.request({ method: 'eth_chainId' });
+ if (chainId !== '0xa4b1') {
+ await switchToArbitrum();
+ // If switch failed or user cancelled, stop
+ // NOTE: switchToArbitrum handles errors but we need to check execution
+ // @ts-ignore
+ const newChainId = await walletProvider.request({ method: 'eth_chainId' });
+ if (newChainId !== '0xa4b1') {
+ setIsLoading(false);
+ return;
+ }
+ }
+ }
+
+ // Check ETH Balance for gas
+ try {
+ if (walletProvider) {
+ const provider = new ethers.providers.Web3Provider(
+ walletProvider as any
+ );
+ const ethBalance = await provider.getBalance(address);
+ if (ethBalance.lt(ethers.utils.parseEther('0.0001'))) {
+ toast.error('Insufficient ETH', {
+ description: 'You need ETH on Arbitrum for gas fees.',
+ });
+ setIsLoading(false);
+ return;
+ }
+ }
+ } catch (e) {
+ console.warn('Failed to check ETH balance:', e);
+ }
+
+ const amountInWei = parseUnits(amount, 6);
+
+ // Transfer USDC directly to bridge contract using viem walletClient
+ toast.info('Confirming Transaction', {
+ description: 'Please sign the transfer transaction in your wallet...',
+ });
+
+ const txHash = await walletClient.writeContract({
+ account: address as `0x${string}`,
+ address: USDC_CONTRACT_ADDRESS,
+ abi: erc20Abi,
+ functionName: 'transfer',
+ args: [BRIDGE_CONTRACT_ADDRESS as `0x${string}`, amountInWei],
+ });
+
+ if (txHash) {
+ toast.success('Success!', {
+ description: `Bridging ${amount} USDC. It will arrive in 5-10 minutes.`,
+ action: {
+ label: 'View on Arbiscan',
+ onClick: () => window.open(`https://arbiscan.io/tx/${txHash}`, '_blank'),
+ },
+ });
+ setOpen(false);
+ setTxHash(null);
+ setAmount('');
+ } else {
+ throw new Error('Transaction failed');
+ }
+ } catch (error: any) {
+ console.error('Bridge error:', error);
+ toast.error('Bridge Failed', {
+ description: error.message || 'Failed to bridge USDC',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Clean up float for display
+ const ethDisplay = arbitrumEthBalance ? parseFloat(arbitrumEthBalance).toFixed(4) : '0';
+
+ const isLowEth = arbitrumEthBalance && parseFloat(arbitrumEthBalance) < 0.0001;
+
+ const currentBalance = userState?.marginSummary?.accountValue || '0';
+ // ...
+
+ Min: 0.0001 ETH
+
+
+ return (
+
+ );
+}
diff --git a/src/apps/perps/components/PasteStrategyButton.tsx b/src/apps/perps/components/PasteStrategyButton.tsx
new file mode 100644
index 00000000..3920a0fd
--- /dev/null
+++ b/src/apps/perps/components/PasteStrategyButton.tsx
@@ -0,0 +1,135 @@
+import { useState } from 'react';
+import { Button } from './ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from './ui/dialog';
+import { Textarea } from './ui/textarea';
+import { Label } from './ui/label';
+import { ClipboardPaste } from 'lucide-react';
+import { toast } from 'sonner';
+
+interface StrategyPayload {
+ orderSide: 'buy' | 'sell';
+ ticker: string;
+ exchange: string;
+ entryPrice: number;
+ stopLoss: number;
+ tp1?: number;
+ tp2?: number;
+ tp3?: number;
+ tp4?: number;
+ tp5?: number;
+}
+
+interface ParsedStrategy {
+ ticker: string;
+ side: 'long' | 'short';
+ entryPrice: number;
+ stopLoss: number;
+ takeProfits: string;
+}
+
+interface PasteStrategyButtonProps {
+ onStrategyPasted: (strategy: ParsedStrategy) => void;
+}
+
+export function PasteStrategyButton({
+ onStrategyPasted,
+}: PasteStrategyButtonProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [jsonInput, setJsonInput] = useState('');
+
+ const parseStrategy = (json: string): ParsedStrategy | null => {
+ try {
+ const payload: StrategyPayload = JSON.parse(json);
+
+ // Validate required fields
+ if (!payload.ticker || !payload.orderSide || !payload.entryPrice) {
+ toast.error('Invalid strategy payload', {
+ description:
+ 'Missing required fields: ticker, orderSide, or entryPrice',
+ });
+ return null;
+ }
+
+ // Extract ticker symbol (remove USDT.P suffix)
+ const ticker = payload.ticker.replace(/USDT\.P$/i, '');
+
+ // Convert orderSide to long/short
+ const side = payload.orderSide === 'buy' ? 'long' : 'short';
+
+ // Collect take profits (tp1, tp2, tp3)
+ const tps: number[] = [];
+ if (payload.tp1) tps.push(payload.tp1);
+ if (payload.tp2) tps.push(payload.tp2);
+ if (payload.tp3) tps.push(payload.tp3);
+
+ return {
+ ticker,
+ side,
+ entryPrice: payload.entryPrice,
+ stopLoss: payload.stopLoss,
+ takeProfits: tps.join(', '),
+ };
+ } catch (error) {
+ toast.error('Invalid JSON', {
+ description: 'Please paste a valid JSON strategy payload',
+ });
+ return null;
+ }
+ };
+
+ const handlePaste = () => {
+ const strategy = parseStrategy(jsonInput);
+ if (strategy) {
+ onStrategyPasted(strategy);
+ setIsOpen(false);
+ setJsonInput('');
+ toast.success('Strategy loaded!', {
+ description: `${strategy.side.toUpperCase()} ${strategy.ticker} @ $${strategy.entryPrice}`,
+ });
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/apps/perps/components/PinSetupModal.tsx b/src/apps/perps/components/PinSetupModal.tsx
new file mode 100644
index 00000000..9904a9e7
--- /dev/null
+++ b/src/apps/perps/components/PinSetupModal.tsx
@@ -0,0 +1,129 @@
+import { useState, useRef, useEffect } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from './ui/dialog';
+import { Button } from './ui/button';
+import { Label } from './ui/label';
+import { Lock } from 'lucide-react';
+import { toast } from 'sonner';
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSlot,
+} from './ui/input-otp';
+
+interface PinSetupModalProps {
+ isOpen: boolean;
+ onConfirm: (pin: string) => void;
+ onCancel: () => void;
+}
+
+export function PinSetupModal({ isOpen, onConfirm, onCancel }: PinSetupModalProps) {
+ const [pin, setPin] = useState('');
+ const [confirmPin, setConfirmPin] = useState('');
+ const confirmInputRef = useRef>(null);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (pin.length !== 4) {
+ toast.error('PIN must be 4 digits');
+ return;
+ }
+ if (pin !== confirmPin) {
+ toast.error('PINs do not match');
+ return;
+ }
+
+ onConfirm(pin);
+ // Reset state
+ setPin('');
+ setConfirmPin('');
+ };
+
+ // Auto-focus confirm input when PIN is complete
+ useEffect(() => {
+ if (pin.length === 4) {
+ confirmInputRef.current?.focus();
+ }
+ }, [pin]);
+
+ // Auto-submit when confirm PIN matches
+ useEffect(() => {
+ if (confirmPin.length === 4) {
+ if (confirmPin === pin) {
+ onConfirm(pin);
+ setPin('');
+ setConfirmPin('');
+ } else {
+ toast.error('PINs do not match');
+ // Optional: Clear confirm pin to let them try again?
+ // Let's keep it so they can see what they typed or backspace.
+ }
+ }
+ }, [confirmPin, pin, onConfirm]);
+
+ return (
+
+ );
+}
diff --git a/src/apps/perps/components/PositionCard.tsx b/src/apps/perps/components/PositionCard.tsx
new file mode 100644
index 00000000..a1f8cc92
--- /dev/null
+++ b/src/apps/perps/components/PositionCard.tsx
@@ -0,0 +1,272 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
+import { Badge } from './ui/badge';
+import {
+ TrendingUp,
+ TrendingDown,
+ Target,
+ Shield,
+ Trophy,
+ RefreshCw,
+} from 'lucide-react';
+import {
+ getUserState,
+ getOpenOrders,
+ getMarkPrice,
+} from '../lib/hyperliquid/client';
+import {
+ parsePositionForSymbol,
+ parseReduceOnlyOrders,
+} from '../lib/hyperliquid/parsers';
+import { computePnl, formatPrice, formatPnl } from '../lib/hyperliquid/pnl';
+import { cn } from '../lib/utils';
+
+interface PositionCardProps {
+ symbol: string;
+ address?: `0x${string}` | string;
+}
+
+export function PositionCard({ symbol, address }: PositionCardProps) {
+ const [loading, setLoading] = useState(false);
+ const [side, setSide] = useState<'long' | 'short' | null>(null);
+ const [size, setSize] = useState(0);
+ const [entryPx, setEntryPx] = useState(0);
+ const [markPx, setMarkPx] = useState(0);
+ const [stopLoss, setStopLoss] = useState();
+ const [takeProfits, setTakeProfits] = useState([]);
+ const [lastUpdate, setLastUpdate] = useState(null);
+
+ useEffect(() => {
+ if (!address || !symbol) return;
+
+ let alive = true;
+
+ async function load() {
+ try {
+ setLoading(true);
+
+ const [state, orders, mark] = await Promise.all([
+ getUserState(address as string),
+ getOpenOrders(address as string, symbol),
+ getMarkPrice(symbol),
+ ]);
+
+ if (!alive) return;
+
+ // Parse position for the symbol
+ const pos = state ? parsePositionForSymbol(state, symbol) : null;
+
+ if (pos) {
+ setSide(pos.side);
+ setSize(pos.size);
+ setEntryPx(pos.entryPx);
+
+ // Parse SL/TP from reduce-only orders
+ const { sl, tps } = parseReduceOnlyOrders(
+ orders,
+ symbol,
+ pos.side,
+ pos.entryPx
+ );
+ setStopLoss(sl);
+ setTakeProfits(tps);
+ } else {
+ setSide(null);
+ setSize(0);
+ setEntryPx(0);
+ setStopLoss(undefined);
+ setTakeProfits([]);
+ }
+
+ setMarkPx(mark ?? 0);
+ setLastUpdate(new Date());
+ } catch (error) {
+ console.error('Error loading position:', error);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ load();
+ const id = setInterval(load, 2000);
+
+ return () => {
+ alive = false;
+ clearInterval(id);
+ };
+ }, [address, symbol]);
+
+ const pnl = useMemo(() => {
+ if (!side || !size || !entryPx || !markPx) {
+ return { pnlUsd: 0, pnlPct: 0 };
+ }
+ return computePnl(side, size, entryPx, markPx);
+ }, [side, size, entryPx, markPx]);
+
+ if (!address) {
+ return null;
+ }
+
+ if (!side) {
+ return (
+
+
+
+ Position: {symbol}
+
+ No Position
+
+
+
+
+
+ No open {symbol} position
+
+
+
+ );
+ }
+
+ const isProfitable = pnl.pnlUsd > 0;
+ const isLong = side === 'long';
+
+ return (
+
+
+
+
+ Position: {symbol}
+
+ {isLong ? (
+ <>
+
+ LONG
+ >
+ ) : (
+ <>
+
+ SHORT
+ >
+ )}
+
+
+
+
+ {lastUpdate && {lastUpdate.toLocaleTimeString()}}
+
+
+
+
+ {/* Size and Prices */}
+
+
+
Size
+
+ {formatPrice(size, 4)}
+
+
+
+
Entry Price
+
+ ${formatPrice(entryPx, 2)}
+
+
+
+
+ {/* Mark Price */}
+
+
+ Current Mark Price
+
+
+ ${formatPrice(markPx, 2)}
+
+
+
+ {/* PnL */}
+
+
Unrealized PnL
+
+
+ {formatPnl(pnl.pnlUsd)}
+
+
+ ({formatPnl(pnl.pnlPct, true)})
+
+
+
+
+ {/* SL and TP */}
+
+ {stopLoss && (
+
+
+
+
+
+
Stop Loss
+
+ ${formatPrice(stopLoss, 2)}
+
+
+
+ )}
+
+ {takeProfits.length > 0 && (
+
+
+
+
+
+
+ Take Profit Targets
+
+
+ {takeProfits.map((tp, idx) => (
+
+ ${formatPrice(tp, 2)}
+
+ ))}
+
+
+
+ )}
+
+ {!stopLoss && takeProfits.length === 0 && (
+
+ No SL/TP orders detected
+
+ )}
+
+
+
+ );
+}
diff --git a/src/apps/perps/components/PositionsCard.tsx b/src/apps/perps/components/PositionsCard.tsx
new file mode 100644
index 00000000..88b3b6e3
--- /dev/null
+++ b/src/apps/perps/components/PositionsCard.tsx
@@ -0,0 +1,1317 @@
+import { useState, useEffect, useCallback } from 'react';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '../components/ui/card';
+import { Skeleton } from '../components/ui/skeleton';
+import { Button } from '../components/ui/button';
+import { RefreshCw, X, ChevronLeft, AlertTriangle } from 'lucide-react';
+import { useIsMobile } from '../hooks/use-mobile';
+import { getUserState } from '../lib/hyperliquid/client';
+import type {
+ HyperliquidPosition,
+ HyperliquidOrder,
+ UniverseAsset,
+} from '../lib/hyperliquid/types';
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '../components/ui/collapsible';
+import { ChevronDown } from 'lucide-react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+ DialogTrigger,
+} from '../components/ui/dialog';
+import { Input } from '../components/ui/input';
+import { Label } from '../components/ui/label';
+import { Slider } from '../components/ui/slider';
+import { toast } from 'sonner';
+import {
+ getAgentWallet,
+ getImportedAccount,
+ isAgentWalletEncrypted,
+ unlockAgentWallet,
+ unlockImportedAccount,
+ isImportedAccountEncrypted,
+} from '../lib/hyperliquid/keystore';
+import {
+ placeMarketOrderAgent,
+ cancelOrderAgent,
+} from '../lib/hyperliquid/sdk';
+import {
+ getMarkPrice,
+ getOpenOrders,
+ getFrontendOpenOrders,
+ getMetaAndAssetCtxs,
+ getUserFills,
+} from '../lib/hyperliquid/client';
+import { TokenIcon } from './TokenIcon';
+import { UnlockWalletModal } from './UnlockWalletModal';
+
+interface PositionsCardProps {
+ masterAddress: string;
+ onPositionClick?: (symbol: string) => void;
+ onRefresh?: () => void;
+ userState?: any; // Using any for now to avoid strict type issues with passed state, or userState?: UserState;
+ assetPositions?: HyperliquidPosition[]; // Direct pass-through
+ openOrders?: HyperliquidOrder[];
+}
+
+export function PositionsCard({
+ masterAddress,
+ onPositionClick,
+ userState,
+ assetPositions: directPositions, // Rename
+ openOrders: externalOpenOrders,
+ onRefresh,
+}: PositionsCardProps) {
+ const isMobile = useIsMobile();
+ const [positions, setPositions] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isOpen, setIsOpen] = useState(true);
+ const [expandedPositionIndex, setExpandedPositionIndex] = useState<
+ number | null
+ >(null);
+ const [positionToClose, setPositionToClose] = useState(null);
+ const [closePercentage, setClosePercentage] = useState(100);
+ const [isClosing, setIsClosing] = useState(false);
+ const [closeDialogOpen, setCloseDialogOpen] = useState(false);
+ const [universe, setUniverse] = useState([]);
+ const [openOrders, setOpenOrders] = useState([]);
+ const [internalUserState, setInternalUserState] = useState(null);
+
+ // Unlock Modal State
+ const [showUnlockModal, setShowUnlockModal] = useState(false);
+ const [pendingAction, setPendingAction] = useState<{ type: 'close' | 'cancel', data?: any } | null>(null);
+
+
+ const handlePositionClick = (coin: string) => {
+ // 1. Load asset into chart/trade form via parent
+ if (onPositionClick) {
+ onPositionClick(coin);
+ }
+ };
+
+ const fetchData = useCallback(async () => {
+ if (!masterAddress) return;
+
+ setIsLoading(true);
+ try {
+ // If we have external data, we only need metadata and prices
+ // checks if we need to fetch user data
+ const shouldFetchUser = !userState && !directPositions; // Only fetch if no external userState or directPositions
+
+ const promises: Promise[] = [
+ getMetaAndAssetCtxs(),
+ ];
+
+ if (shouldFetchUser) {
+ promises.push(getUserState(masterAddress));
+ promises.push(getFrontendOpenOrders(masterAddress));
+ }
+
+ const results = await Promise.all(promises);
+ const metaData = results[0];
+ const fetchedUserState = shouldFetchUser ? results[1] : userState;
+ const fetchedOrders = shouldFetchUser ? results[2] : externalOpenOrders;
+
+ if (shouldFetchUser) {
+ setInternalUserState(fetchedUserState);
+ }
+
+ let currentUniverse = universe;
+ const priceMap: Record = {};
+ if (
+ metaData &&
+ Array.isArray(metaData) &&
+ metaData[0]?.universe &&
+ Array.isArray(metaData[1])
+ ) {
+ const universeData = metaData[0].universe;
+ setUniverse(universeData);
+ currentUniverse = universeData; // Use fresh data for immediate lookup
+ const assetCtxs = metaData[1];
+
+ universeData.forEach((asset: any, index: number) => {
+ const ctx = assetCtxs[index];
+ if (ctx && ctx.markPx) {
+ priceMap[asset.name] = parseFloat(ctx.markPx);
+ }
+ });
+ }
+
+ const effectiveState = userState || internalUserState;
+ // Prefer direct positions if available, otherwise check state
+ const sourcePositions = directPositions || effectiveState?.assetPositions;
+
+ if (sourcePositions) {
+ const uniqueKeys = new Set();
+
+ // Robust Mapping Logic
+ const openPositions = sourcePositions
+ .map((pos: any) => {
+ try {
+ // Handle potential structure mismatch (wrapped vs flat)
+ const rawPos = pos.position || pos;
+
+ // Ensure uniqueness based on coin
+ if (uniqueKeys.has(rawPos.coin)) {
+ console.warn(`Duplicate position for ${rawPos.coin} found, skipping.`);
+ return null;
+ }
+ uniqueKeys.add(rawPos.coin);
+
+ // Enrich with mark price if missing or zero
+ let markPx = rawPos.markPx;
+ if (
+ (!markPx || parseFloat(markPx) === 0) &&
+ priceMap[rawPos.coin]
+ ) {
+ markPx = priceMap[rawPos.coin].toString();
+ }
+
+ // Robust Universe Lookup
+ let coinInfo = currentUniverse.find((u) => u.name === rawPos.coin);
+
+ if (!coinInfo) {
+ // FALLBACK: If coin not found in universe, use default formatting
+ // This ensures we show the position even if metadata is missing/loading
+ console.warn(`Warning: Universe metadata missing for ${rawPos.coin}, using defaults`);
+ coinInfo = {
+ name: rawPos.coin,
+ szDecimals: 4, // Default for most assets
+ maxLeverage: 50,
+ };
+ }
+
+ return {
+ ...rawPos,
+ markPx,
+ coinInfo,
+ };
+ } catch (e) {
+ console.warn('CRITICAL WARNING: Error processing position item:', e, pos);
+ return null;
+ }
+ })
+ .filter((p: any) => !!p); // Only filter nulls/errors, allow 0 size for debugging
+
+ setPositions(openPositions);
+ } else {
+ setPositions([]); // Clear positions if none found
+ }
+
+ setOpenOrders(fetchedOrders || []);
+ } catch (error) {
+ console.error('Error fetching data:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [masterAddress, userState, directPositions, externalOpenOrders, internalUserState]); // Added internalUserState to dependencies, removed universe
+
+ useEffect(() => {
+ fetchData();
+ // Only poll if we don't have external data (to avoid rate limiting)
+ if (!userState && !directPositions) {
+ const interval = setInterval(fetchData, 10000);
+ return () => clearInterval(interval);
+ }
+ }, [fetchData, userState, directPositions]); // Re-run when external data changes
+
+ // Auto-collapse when there are no positions or orders, auto-open when there are
+ useEffect(() => {
+ if (positions.length === 0 && openOrders.length === 0) {
+ setIsOpen(false);
+ } else {
+ setIsOpen(true);
+ }
+ }, [positions.length, openOrders.length]);
+
+ const formatNumber = (
+ value: string | number,
+ decimals: number = 2
+ ): string => {
+ if (!value) return '-';
+ return parseFloat(value.toString()).toFixed(decimals);
+ };
+
+ const formatPrice = (value: string | number): string => {
+ if (!value) return '-';
+ const val = parseFloat(value.toString());
+ if (val === 0) return '0.00';
+ if (Math.abs(val) < 1) return val.toFixed(5);
+ return val.toFixed(2);
+ };
+
+ const formatPnl = (pnl: string) => {
+ const pnlNum = parseFloat(pnl);
+ const formatted = formatNumber(pnlNum, 2);
+ const className = pnlNum >= 0 ? 'text-green-500' : 'text-red-500';
+ return { formatted, className };
+ };
+
+ const calculateLeverage = (position: any): string => {
+ if (position.leverage && typeof position.leverage.value === 'number') {
+ return `${position.leverage.value}x`;
+ }
+ // Fallback to effective leverage calculation if SDK value is missing
+ const positionValue =
+ Math.abs(parseFloat(position.szi)) * parseFloat(position.entryPx);
+ const marginUsed = parseFloat(position.marginUsed);
+ if (marginUsed === 0) return '0x';
+ return `${formatNumber(positionValue / marginUsed, 1)}x`;
+ };
+
+ const handleOpenCloseDialog = (position: any) => {
+ setPositionToClose(position);
+ setClosePercentage(100);
+ setCloseDialogOpen(true);
+ };
+
+ /* Removed getCoinId as we reuse cached universe */
+
+ const handleExecuteClose = async () => {
+ if (!positionToClose) return;
+ setIsClosing(true);
+ try {
+ let privateKey: string | undefined;
+
+ // 1. Check for Imported Account (Priority)
+ const imported = getImportedAccount();
+ if (imported) {
+ privateKey = imported.privateKey;
+ }
+ // 2. Fallback to Agent Wallet linked to connected wallet
+ else {
+ const agent = await getAgentWallet(masterAddress);
+ if (agent?.approved) {
+ privateKey = agent.privateKey;
+ }
+ }
+
+ if (!privateKey) {
+ // CHECK FOR LOCKED STATE
+ if (isImportedAccountEncrypted() || isAgentWalletEncrypted(masterAddress)) {
+ setPendingAction({ type: 'close' });
+ setShowUnlockModal(true);
+ setIsClosing(false); // Reset loading state
+ return;
+ }
+
+ throw new Error(
+ 'Agent not found. Please import an account or create an agent.'
+ );
+ }
+
+ // Use cached universe to find coin ID
+ const coinIndex = universe.findIndex(
+ (a: any) => a.name === positionToClose.coin
+ );
+ if (coinIndex === -1) {
+ throw new Error(`Asset ${positionToClose.coin} not found in metadata`);
+ }
+ const coinId = coinIndex;
+
+ const totalSize = Math.abs(parseFloat(positionToClose.szi));
+ const sizeToClose = totalSize * (closePercentage / 100);
+ const sizeStr = sizeToClose.toFixed(6);
+ const size = parseFloat(sizeStr);
+
+ const currentPrice =
+ parseFloat(positionToClose.markPx) ||
+ parseFloat(positionToClose.entryPx);
+ const isLong = parseFloat(positionToClose.szi) > 0;
+
+ await placeMarketOrderAgent(privateKey as `0x${string}`, {
+ coinId,
+ isBuy: !isLong,
+ size,
+ currentPrice,
+ reduceOnly: true,
+ });
+
+ toast.success('Order submitted');
+ setCloseDialogOpen(false);
+ setTimeout(() => {
+ fetchData();
+ onRefresh?.();
+ }, 1000);
+ setExpandedPositionIndex(null);
+ } catch (e: any) {
+ toast.error(e.message);
+ } finally {
+ setIsClosing(false);
+ }
+ };
+
+ const isBuy = (side: string) => side === 'B';
+
+ const handleCancelOrder = async (oid: number) => {
+ let loadingToast: string | number | undefined;
+ try {
+ // Find the order to get the coin/asset info
+ const order = openOrders.find((o) => o.oid === oid);
+ if (!order) {
+ toast.error('Order not found');
+ return;
+ }
+
+ loadingToast = toast.info('Canceling order...', {
+ description: `${order.coin} - Order ID: ${oid}`,
+ });
+
+ let privateKey: string | undefined;
+
+ // 1. Check for Imported Account (Priority)
+ const imported = getImportedAccount();
+ if (imported) {
+ privateKey = imported.privateKey;
+ }
+ // 2. Fallback to Agent Wallet linked to connected wallet
+ else {
+ const agent = await getAgentWallet(masterAddress);
+ if (agent?.approved) {
+ privateKey = agent.privateKey;
+ }
+ }
+
+ if (!privateKey) {
+ // CHECK FOR LOCKED STATE
+ if (isImportedAccountEncrypted() || isAgentWalletEncrypted(masterAddress)) {
+ toast.dismiss(loadingToast);
+ setPendingAction({ type: 'cancel', data: oid });
+ setShowUnlockModal(true);
+ return;
+ }
+
+ toast.dismiss(loadingToast);
+ toast.error('Agent wallet not found');
+ return;
+ }
+
+ // Use cached universe to find coin ID
+ const coinIndex = universe.findIndex((a: any) => a.name === order.coin);
+ let coinId: number | undefined;
+
+ if (coinIndex !== -1) {
+ coinId = coinIndex;
+ }
+
+ if (coinId === undefined) {
+ toast.dismiss(loadingToast);
+ toast.error('Could not find asset ID', {
+ description: `Asset: ${order.coin}`,
+ });
+ return;
+ }
+
+ await cancelOrderAgent(privateKey as `0x${string}`, {
+ coinId: coinId,
+ oid: oid,
+ });
+
+ toast.dismiss(loadingToast);
+ toast.success('Order canceled successfully', {
+ description: `${order.coin}`,
+ });
+
+ // Refresh orders list
+ await fetchData();
+ onRefresh?.();
+ } catch (e: any) {
+ console.error('Error canceling order:', e);
+ if (loadingToast) toast.dismiss(loadingToast);
+ toast.error('Failed to cancel order', {
+ description: e.message || 'Unknown error',
+ });
+ }
+ };
+
+ // Helper to aggregate TP and SL orders for a specific coin
+ const getOpenTP_SL = (coin: string, positionSize: number) => {
+ const positionOrders = openOrders.filter(
+ (o) => o.coin === coin && o.reduceOnly
+ );
+
+ // Determine position direction (Long > 0, Short < 0)
+ const isLong = positionSize > 0;
+
+ const tps: { price: number; size: number }[] = [];
+ const sls: { price: number; size: number }[] = [];
+
+ positionOrders.forEach((order) => {
+ // Determine if it's a closing order (Long needs Sell, Short needs Buy)
+ const isBuy = order.side === 'B';
+ const isClosing = (isLong && !isBuy) || (!isLong && isBuy);
+
+ if (isClosing) {
+ // Get trigger price - API returns it directly on order object
+ const triggerPx = parseFloat(
+ order.triggerPx ||
+ order.trigger?.triggerPx ||
+ order.triggerCondition?.triggerPx ||
+ '0'
+ );
+ const limitPx = parseFloat(order.limitPx || '0');
+ const price = triggerPx > 0 ? triggerPx : limitPx;
+ const size = parseFloat(order.sz);
+
+ // Classify as TP or SL using orderType from API
+ if (
+ order.orderType &&
+ order.orderType.toLowerCase().includes('take profit')
+ ) {
+ tps.push({ price, size });
+ } else if (
+ order.orderType &&
+ order.orderType.toLowerCase().includes('stop')
+ ) {
+ sls.push({ price, size });
+ } else if (order.reduceOnly) {
+ // Fallback: classify based on price comparison for reduceOnly orders
+ const position = positions.find((p) => p.coin === coin);
+ const markPx = parseFloat(
+ position?.markPx || position?.entryPx || '0'
+ );
+
+ if (markPx > 0 && price > 0) {
+ if (isLong) {
+ // Long: TP > Mark, SL < Mark
+ if (price > markPx) tps.push({ price, size });
+ else sls.push({ price, size });
+ } else {
+ // Short: TP < Mark, SL > Mark
+ if (price < markPx) tps.push({ price, size });
+ else sls.push({ price, size });
+ }
+ }
+ }
+ }
+ });
+
+ return {
+ tps: tps.sort((a, b) => a.price - b.price),
+ sls: sls.sort((a, b) => a.price - b.price),
+ };
+ };
+
+
+ const handleUnlock = async (pin: string): Promise => {
+ try {
+ // Try unlocking imported account first
+ if (isImportedAccountEncrypted()) {
+ const unlocked = await unlockImportedAccount(pin);
+ if (unlocked) {
+ setShowUnlockModal(false);
+ // Retry pending action
+ if (pendingAction?.type === 'close') {
+ handleExecuteClose();
+ } else if (pendingAction?.type === 'cancel' && pendingAction.data) {
+ handleCancelOrder(pendingAction.data);
+ }
+ setPendingAction(null);
+ return true;
+ }
+ }
+
+ // Try unlocking agent wallet
+ if (isAgentWalletEncrypted(masterAddress)) {
+ const unlocked = await unlockAgentWallet(masterAddress, pin);
+ if (unlocked) {
+ setShowUnlockModal(false);
+ // Retry pending action
+ if (pendingAction?.type === 'close') {
+ handleExecuteClose();
+ } else if (pendingAction?.type === 'cancel' && pendingAction.data) {
+ handleCancelOrder(pendingAction.data);
+ }
+ setPendingAction(null);
+ return true;
+ }
+ }
+ } catch (e) {
+ console.error('Unlock failed', e);
+ }
+ return false;
+ };
+
+ return (
+
+ setShowUnlockModal(false)}
+ />
+
+
+
+
+
+
+ Positions & Orders
+
+
+
+
+
+
+
+
+
+ Open Positions
+
+ {positions.length}
+
+
+
+ {positions.length === 0 ? (
+
+ No open positions
+
+ ) : isLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : expandedPositionIndex !== null &&
+ positions[expandedPositionIndex] ? (
+ (() => {
+ const position = positions[expandedPositionIndex];
+ const pnl = formatPnl(position.unrealizedPnl);
+ const marginUsedVal = parseFloat(position.marginUsed);
+ const roe =
+ marginUsedVal > 0
+ ? (parseFloat(position.unrealizedPnl) / marginUsedVal) * 100
+ : 0;
+ const isLong = parseFloat(position.szi) > 0;
+ const leverage = calculateLeverage(position);
+
+ // Get aggregated TPs and SLs
+ const { tps, sls } = getOpenTP_SL(
+ position.coin,
+ parseFloat(position.szi)
+ );
+
+ return (
+
+
+
+
+ Open Position
+
+
+
+
+
+
+
+
+ {position.coin}
+
+
+ {isLong ? 'Long' : 'Short'} {leverage}
+
+
+
+
+
+ ${pnl.formatted}
+
+
{formatNumber(roe, 2)}%
+
+
+
+
+
+ Trade Details
+
+
+
+
+ Entry Price
+
+
+ ${formatPrice(position.entryPx)}
+
+
+
+
+ Mark Price
+
+
+ ${formatPrice(position.markPx || '0')}
+
+
+
+
+ Size ({position.coin})
+
+
+ {formatNumber(
+ Math.abs(parseFloat(position.szi)),
+ 4
+ )}
+
+
+
+
+ Liq. Price
+
+
+ {position.liquidationPx
+ ? `$${formatPrice(position.liquidationPx)}`
+ : '-'}
+
+
+
+
+ Margin Mode
+
+
+ {position.leverage?.type === 'isolated'
+ ? 'Isolated'
+ : 'Cross'}
+
+
+
+
+
+ Take Profit
+
+ {tps.length > 0 ? (
+
+ {tps
+ .map((tp) => `$${formatPrice(tp.price)}`)
+ .join(', ')}
+
+ ) : (
+
+ {position.tpPrice
+ ? `$${formatPrice(position.tpPrice)}`
+ : '-'}
+
+ )}
+
+
+
+ Stop Loss
+
+ {sls.length > 0 ? (
+
+ {sls
+ .map((sl) => `$${formatPrice(sl.price)}`)
+ .join(', ')}
+
+ ) : (
+
+ {position.slPrice
+ ? `$${formatPrice(position.slPrice)}`
+ : '-'}
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+ })()
+ ) : (
+
+ {!isMobile && (
+
+
Coin
+
Size
+
Entry
+
Mark
+
Liq. Px
+
TP / SL
+
Margin
+
PnL
+
+ )}
+
+ {positions.map((position, index) => {
+ const pnl = formatPnl(position.unrealizedPnl);
+ const marginUsedVal = parseFloat(position.marginUsed);
+ const roe =
+ marginUsedVal > 0
+ ? (parseFloat(position.unrealizedPnl) / marginUsedVal) * 100
+ : 0;
+ const isLong = parseFloat(position.szi) > 0;
+ const positionValue =
+ Math.abs(parseFloat(position.szi)) *
+ parseFloat(position.markPx || '0');
+ const marginUsed = parseFloat(position.marginUsed || '0');
+ const leverage = position.leverage?.value || 0;
+
+ const { tps, sls } = getOpenTP_SL(
+ position.coin,
+ parseFloat(position.szi)
+ );
+ const hasOrders = tps.length > 0 || sls.length > 0;
+
+ if (isMobile) {
+ return (
+
{
+ handlePositionClick(position.coin);
+ setExpandedPositionIndex(index);
+ }}
+ className="flex flex-col gap-3 p-4 bg-card/50 hover:bg-muted/50 rounded-lg border border-border/50 mb-3 cursor-pointer"
+ >
+
+
+
+
+
+ {position.coin}
+
+ {isLong ? 'LONG' : 'SHORT'}
+
+
+
+ {isLong ? 'Long' : 'Short'}
+
+ • {leverage}x
+
+
+
+
+
+
+ ${pnl.formatted}{' '}
+
+ ({formatNumber(roe, 1)}%)
+
+
+
+
+
+
+
+
+ Size
+
+
+ {formatNumber(position.szi, 3)}
+
+
+
+
+ Liq. Price
+
+
+ {position.liquidationPx
+ ? `$${formatPrice(position.liquidationPx)}`
+ : '-'}
+
+
+
+
+
+ Entry
+
+
+ ${formatPrice(position.entryPx)}
+
+
+
+
+ Margin
+
+
+ ${formatNumber(marginUsed, 2)}
+
+
+
+
+
+ Mark
+
+
+ ${formatPrice(position.markPx || '0')}
+
+
+
+
+ TP / SL
+
+
+ {hasOrders ? (
+
+
+ {(() => {
+ // Show closest orders only
+ const closestTp = isLong
+ ? tps[0]
+ : tps[tps.length - 1];
+ const closestSl = isLong
+ ? sls[sls.length - 1]
+ : sls[0];
+
+ return (
+
+ {closestTp && (
+
+ {' '}
+ ${formatPrice(closestTp.price)}
+
+ )}
+ {closestSl && (
+
+ {' '}
+ ${formatPrice(closestSl.price)}
+
+ )}
+
+ );
+ })()}
+
+
+ ) : (
+
+ {tps.length === 0 && sls.length === 0 ? (
+ -
+ ) : (
+ <>
+ {tps.length > 0 && (
+
+ ${formatPrice(tps[0].price)}
+
+ )}
+ {sls.length > 0 && (
+
+ ${formatPrice(sls[0].price)}
+
+ )}
+ >
+ )}
+
+ )}
+
+
+
+
+ );
+ }
+
+ return (
+
{
+ handlePositionClick(position.coin);
+ setExpandedPositionIndex(index);
+ }}
+ className="grid grid-cols-[1.2fr_0.8fr_0.8fr_0.8fr_0.8fr_0.8fr_0.8fr_1fr] gap-2 p-3 bg-card/50 hover:bg-muted/50 active:bg-muted transition-colors rounded-lg items-center text-sm cursor-pointer border border-transparent hover:border-border/50 text-right"
+ >
+
+
+
+ {position.coin}
+
+ {isLong ? 'LONG' : 'SHORT'}
+
+
+ {calculateLeverage(position)}
+
+
+
+
+ {formatNumber(position.szi, 3)}
+
+
+ ${formatPrice(position.entryPx)}
+
+
+ ${formatPrice(position.markPx || '0')}
+
+
+ {position.liquidationPx
+ ? `$${formatPrice(position.liquidationPx)}`
+ : '-'}
+
+
+ {hasOrders ? (
+
+ {(() => {
+ // Show closest orders only
+ const closestTp = isLong
+ ? tps[0]
+ : tps[tps.length - 1];
+ const closestSl = isLong
+ ? sls[sls.length - 1]
+ : sls[0];
+
+ return (
+ <>
+ {closestTp && (
+
+ ${formatPrice(closestTp.price)}
+
+ )}
+ {closestSl && (
+
+ ${formatPrice(closestSl.price)}
+
+ )}
+ >
+ );
+ })()}
+
+ ) : (
+
+ {tps.length === 0 && sls.length === 0 ? (
+ -
+ ) : (
+ <>
+ {tps.length > 0 && (
+
+ {formatPrice(tps[0].price)}
+
+ )}
+ {sls.length > 0 && (
+
+ {formatPrice(sls[0].price)}
+
+ )}
+ >
+ )}
+
+ )}
+
+
+ ${formatNumber(marginUsed, 2)}
+
+
+
+ ${pnl.formatted}
+
+
+ ({formatNumber(roe, 1)}%)
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ Open Orders
+
+ {openOrders.length}
+
+
+
+ {openOrders.length === 0 ? (
+
+ No open orders
+
+ ) : isLoading ? (
+
+ {[1, 2].map((i) => (
+
+ ))}
+
+ ) : (
+
+ {!isMobile && (
+
+
Coin
+
Type
+
Side
+
Size
+
Price
+
Action
+
+ )}
+
+ {openOrders.map((order, orderIndex) => {
+ const buy = isBuy(order.side);
+ const position = positions.find(
+ (p) => p.coin === order.coin
+ );
+
+ // Determine order type based on reduceOnly and price comparison
+ let type = order.orderType || 'Limit';
+
+ // Normalize trigger price
+ const triggerPxStr = order.triggerPx || order.trigger?.triggerPx || order.triggerCondition?.triggerPx;
+ const hasTrigger = !!triggerPxStr && parseFloat(triggerPxStr) > 0;
+
+ if (order.reduceOnly && position) {
+ const isLong = parseFloat(position.szi) > 0;
+ const isClosing = (isLong && !buy) || (!isLong && buy);
+
+ if (isClosing) {
+ const limitPrice = parseFloat(order.limitPx);
+ const triggerPrice = hasTrigger ? parseFloat(triggerPxStr!) : 0;
+ const executionPrice = hasTrigger ? triggerPrice : limitPrice;
+
+ const markPrice = parseFloat(position.markPx || position.entryPx || '0');
+
+ if (markPrice > 0) {
+ let baseType = 'Limit';
+ if (isLong) {
+ // Long position: TP if price > mark, SL if price < mark
+ baseType = executionPrice > markPrice ? 'Take Profit' : 'Stop Loss';
+ } else {
+ // Short position: TP if price < mark, SL if price > mark
+ baseType = executionPrice < markPrice ? 'Take Profit' : 'Stop Loss';
+ }
+
+ type = `${baseType} ${hasTrigger ? 'Trigger' : 'Limit'}`;
+ }
+ } else {
+ type = 'Reduce Only Limit';
+ }
+ } else if (hasTrigger) {
+ // If not reduced only but has trigger, likely a simpler trigger order
+ type = 'Trigger Order';
+ if (order.orderType) type = order.orderType;
+ }
+
+ let sideLabel = buy ? 'Long' : 'Short';
+ const isLong = position
+ ? parseFloat(position.szi) > 0
+ : false;
+
+ // Override side label for closing orders
+ if (order.reduceOnly && position) {
+ const isClosing = (isLong && !buy) || (!isLong && buy);
+ if (isClosing) {
+ sideLabel = isLong ? 'Close Long' : 'Close Short';
+ }
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+
+
+
+ {order.coin}
+
+
+ {sideLabel}
+
+ {type}
+
+
+
+
+
+
+
+
+
+
+ Size
+
+
+ {formatNumber(order.sz, 4)}
+
+
+
+
+ Price
+
+
+ $
+ {formatPrice(
+ parseFloat(
+ order.triggerPx ||
+ order.trigger?.triggerPx ||
+ order.triggerCondition?.triggerPx ||
+ order.limitPx ||
+ '0'
+ )
+ )}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {order.coin}
+
+
+ {type}
+
+
+ {sideLabel}
+
+
+ {formatNumber(order.sz, 4)}
+
+
+ $
+ {formatPrice(
+ parseFloat(
+ order.triggerPx ||
+ order.trigger?.triggerPx ||
+ order.triggerCondition?.triggerPx ||
+ order.limitPx ||
+ '0'
+ )
+ )}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/apps/perps/components/PriceTicker.tsx b/src/apps/perps/components/PriceTicker.tsx
new file mode 100644
index 00000000..7e731551
--- /dev/null
+++ b/src/apps/perps/components/PriceTicker.tsx
@@ -0,0 +1,229 @@
+import { useEffect, useState } from 'react';
+import type { AssetInfo } from '../lib/hyperliquid/types';
+
+interface PriceTickerProps {
+ selectedAsset: AssetInfo | null;
+}
+
+interface TickerData {
+ markPrice: string;
+ oraclePrice: string;
+ change24h: string;
+ changePercent24h: string;
+ volume24h: string;
+ openInterest: string;
+ fundingRate: string;
+ nextFundingTime: string;
+}
+
+export function PriceTicker({ selectedAsset }: PriceTickerProps) {
+ const [tickerData, setTickerData] = useState(null);
+
+ useEffect(() => {
+ if (!selectedAsset) return;
+
+ let ws: WebSocket | null = null;
+
+ const connect = () => {
+ ws = new WebSocket('wss://api.hyperliquid.xyz/ws');
+
+ ws.onopen = () => {
+ console.log('[Ticker] WebSocket connected');
+ // Subscribe to all mids (prices)
+ const midsMsg = {
+ method: 'subscribe',
+ subscription: { type: 'allMids' },
+ };
+ console.log(
+ '[Ticker] Sending allMids subscription:',
+ JSON.stringify(midsMsg)
+ );
+ ws?.send(JSON.stringify(midsMsg));
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data);
+ console.log('[Ticker] WebSocket message:', message);
+
+ if (message.channel === 'allMids' && message.data) {
+ const price = message.data.mids?.[selectedAsset.symbol];
+ if (price) {
+ setTickerData((prev) =>
+ prev
+ ? {
+ ...prev,
+ markPrice: price,
+ }
+ : null
+ );
+ }
+ }
+ } catch (e) {
+ console.error('[Ticker] WebSocket message error:', e);
+ }
+ };
+
+ ws.onerror = (err) => {
+ console.error('[Ticker] WebSocket error:', err);
+ };
+
+ ws.onclose = () => {
+ console.log('[Ticker] WebSocket disconnected');
+ setTimeout(connect, 3000);
+ };
+ };
+
+ connect();
+
+ return () => {
+ if (ws) {
+ ws.onclose = null;
+ ws.close();
+ }
+ };
+ }, [selectedAsset]);
+
+ // Fetch initial market data from REST API
+ useEffect(() => {
+ if (!selectedAsset) return;
+
+ const fetchMarketData = async () => {
+ try {
+ const response = await fetch('https://api.hyperliquid.xyz/info', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ type: 'metaAndAssetCtxs' }),
+ });
+ const data = await response.json();
+
+ if (data && data.length >= 2) {
+ const assetCtx = data[1]?.find(
+ (ctx: any) => ctx.coin === selectedAsset.symbol
+ );
+ if (assetCtx) {
+ const prevDayPx = parseFloat(assetCtx.prevDayPx || '0');
+ const markPx = parseFloat(assetCtx.markPx || '0');
+ const change24h = markPx - prevDayPx;
+ const changePercent24h =
+ prevDayPx > 0
+ ? ((change24h / prevDayPx) * 100).toFixed(2)
+ : '0.00';
+
+ setTickerData({
+ markPrice: assetCtx.markPx || '0',
+ oraclePrice: assetCtx.oraclePx || '0',
+ change24h: change24h.toFixed(2),
+ changePercent24h,
+ volume24h: assetCtx.dayNtlVlm || '0',
+ openInterest: assetCtx.openInterest || '0',
+ fundingRate: assetCtx.funding || '0',
+ nextFundingTime: '00:00:00', // TODO: Calculate from funding time
+ });
+ }
+ }
+ } catch (error) {
+ console.error('[Ticker] Failed to fetch market data:', error);
+ }
+ };
+
+ fetchMarketData();
+ }, [selectedAsset]);
+
+ if (!selectedAsset || !tickerData) {
+ return null;
+ }
+
+ const isPositive = parseFloat(tickerData.changePercent24h) >= 0;
+
+ return (
+
+ {/* Symbol with icon */}
+
+
+ {selectedAsset.symbol.charAt(0)}
+
+
+ {selectedAsset.symbol}-USDC
+
+
+ {selectedAsset.maxLeverage}x
+
+
+
+ {/* Mark Price */}
+
+ Mark
+
+ {(() => {
+ const price = parseFloat(tickerData.markPrice);
+ if (price < 1 && price > 0) {
+ return '$' + price.toFixed(5);
+ }
+ return '$' + price.toFixed(2);
+ })()}
+
+
+
+ {/* Oracle Price */}
+
+ Oracle
+
+ {(() => {
+ const price = parseFloat(tickerData.oraclePrice);
+ if (price < 1 && price > 0) {
+ return '$' + price.toFixed(5);
+ }
+ return '$' + price.toFixed(2);
+ })()}
+
+
+
+ {/* 24h Change */}
+
+ 24H Change
+
+ {tickerData.change24h} / {isPositive ? '+' : ''}
+ {tickerData.changePercent24h}%
+
+
+
+ {/* 24h Volume */}
+
+ 24H Volume
+
+ $
+ {parseFloat(tickerData.volume24h).toLocaleString('en-US', {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ })}
+
+
+
+ {/* Open Interest */}
+
+ Open Interest
+
+ $
+ {parseFloat(tickerData.openInterest).toLocaleString('en-US', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}
+
+
+
+ {/* Funding Rate */}
+
+
+ Funding / Countdown
+
+
+ {(parseFloat(tickerData.fundingRate) * 100).toFixed(4)}%{' '}
+ {tickerData.nextFundingTime}
+
+
+
+ );
+}
diff --git a/src/apps/perps/components/PrivateKeyModal.tsx b/src/apps/perps/components/PrivateKeyModal.tsx
new file mode 100644
index 00000000..04ea2486
--- /dev/null
+++ b/src/apps/perps/components/PrivateKeyModal.tsx
@@ -0,0 +1,185 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from './ui/dialog';
+import { Button } from './ui/button';
+import { Input } from './ui/input';
+import { Label } from './ui/label';
+import { Copy, Download, CheckCircle2, AlertTriangle } from 'lucide-react';
+import { useState, useRef, useEffect } from 'react';
+import { toast } from 'sonner';
+
+interface PrivateKeyModalProps {
+ isOpen: boolean;
+ address: string;
+ privateKey: string;
+ onClose: () => void;
+ mode?: 'created' | 'revealed';
+ mainAddress?: string; // EOA Address
+}
+
+export function PrivateKeyModal({
+ isOpen,
+ address,
+ privateKey,
+ onClose,
+ mode = 'created',
+ mainAddress,
+}: PrivateKeyModalProps) {
+ const [copied, setCopied] = useState(false);
+ const timerRef = useRef(null);
+
+ // Cleanup timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+ };
+ }, []);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(privateKey);
+ setCopied(true);
+ toast.success('Private key copied to clipboard');
+
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+
+ timerRef.current = setTimeout(() => {
+ setCopied(false);
+ timerRef.current = null;
+ }, 2000);
+ } catch (error) {
+ toast.error('Failed to copy private key');
+ }
+ };
+
+ const handleDownload = () => {
+ let url = '';
+ let a: HTMLAnchorElement | null = null;
+
+ try {
+ // Validation
+ if (!address || !privateKey) {
+ throw new Error('Missing wallet data');
+ }
+
+ const data = JSON.stringify(
+ {
+ address,
+ privateKey,
+ createdAt: new Date().toISOString(),
+ note: "KEEP THIS SAFE. DO NOT SHARE."
+ },
+ null,
+ 2
+ );
+
+ const blob = new Blob([data], { type: 'application/json' });
+ url = URL.createObjectURL(blob);
+ a = document.createElement('a');
+ a.href = url;
+ a.download = `agent-wallet-${address.slice(0, 8)}.json`;
+ document.body.appendChild(a);
+ a.click();
+ toast.success('Key file downloaded');
+ } catch (error) {
+ console.error('Download failed:', error);
+ toast.error(error instanceof Error ? error.message : 'Failed to download key file');
+ } finally {
+ // Cleanup
+ if (a) {
+ document.body.removeChild(a);
+ }
+ if (url) {
+ URL.revokeObjectURL(url);
+ }
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/apps/perps/components/SparklineChart.tsx b/src/apps/perps/components/SparklineChart.tsx
new file mode 100644
index 00000000..fe7210e7
--- /dev/null
+++ b/src/apps/perps/components/SparklineChart.tsx
@@ -0,0 +1,805 @@
+import { useEffect, useState, useCallback } from 'react';
+import type { AssetInfo } from '../lib/hyperliquid/types';
+import { getUserFills } from '../lib/hyperliquid/client';
+import { Card, CardContent } from './ui/card';
+import { useIsMobile } from '../hooks/use-mobile';
+import { TokenIcon } from './TokenIcon';
+
+interface SparklineChartProps {
+ selectedAsset: AssetInfo | null;
+ userState?: any;
+ openOrders?: any[];
+ accountAddress?: string | null;
+}
+
+interface CandleData {
+ time: number;
+ close: number;
+}
+
+interface MarketData {
+ funding: string;
+ openInterest: string;
+ prevDayPx: string;
+ dayNtlVlm: string;
+ premium: string;
+ oraclePx: string;
+ markPx: string;
+ midPx: string;
+ impactPxs: string[];
+ dayBaseVlm: string;
+}
+
+export function SparklineChart({ selectedAsset, userState, openOrders, accountAddress }: SparklineChartProps) {
+ const [candles, setCandles] = useState([]);
+ const [currentPrice, setCurrentPrice] = useState(null);
+ const [marketData, setMarketData] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [hoverData, setHoverData] = useState<{
+ x: number;
+ y: number;
+ price: number;
+ time: number;
+ } | null>(null);
+
+ const fetchMarketData = useCallback(async (symbol: string) => {
+ try {
+ const response = await fetch('https://api.hyperliquid.xyz/info', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ type: 'metaAndAssetCtxs' }),
+ });
+
+ if (!response.ok) throw new Error('Failed to fetch market data');
+
+ const data = await response.json();
+ if (
+ data &&
+ Array.isArray(data) &&
+ data[0]?.universe &&
+ Array.isArray(data[1])
+ ) {
+ const universe = data[0].universe;
+ const assetCtxs = data[1];
+
+ // Find the index of our asset
+ const assetIndex = universe.findIndex((a: any) => a.name === symbol);
+ if (assetIndex !== -1 && assetCtxs[assetIndex]) {
+ setMarketData(assetCtxs[assetIndex]);
+ }
+ }
+ } catch (err) {
+ console.error('[Sparkline] Error fetching market data:', err);
+ }
+ }, []);
+
+ const fetchCandles = useCallback(
+ async (symbol: string) => {
+ try {
+ setIsLoading(true);
+ setError(null);
+
+ const now = Date.now();
+ const twelveHoursAgo = now - 12 * 60 * 60 * 1000;
+
+ const response = await fetch('https://api.hyperliquid.xyz/info', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ type: 'candleSnapshot',
+ req: {
+ coin: symbol,
+ interval: '1m',
+ startTime: twelveHoursAgo,
+ endTime: now,
+ },
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`API error: ${response.status}`);
+ }
+
+ const raw = await response.json();
+
+ if (!Array.isArray(raw)) {
+ throw new Error('Invalid response format');
+ }
+
+ const candleData: CandleData[] = raw
+ .filter((c) => c && c.t && c.c)
+ .map((candle) => ({
+ time: Number(candle.t),
+ close: parseFloat(candle.c),
+ }))
+ .sort((a, b) => a.time - b.time);
+
+ setCandles(candleData);
+
+ if (candleData.length > 0) {
+ setCurrentPrice(candleData[candleData.length - 1].close);
+ }
+
+ // Fetch market data
+ await fetchMarketData(symbol);
+ } catch (err) {
+ console.error('[Sparkline] Error fetching candles:', err);
+ setError(err instanceof Error ? err.message : 'Failed to fetch data');
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [fetchMarketData]
+ );
+
+ useEffect(() => {
+ if (!selectedAsset) {
+ setCandles([]);
+ setCurrentPrice(null);
+ setMarketData(null);
+ return;
+ }
+
+ fetchCandles(selectedAsset.symbol);
+ }, [selectedAsset, fetchCandles]);
+
+ useEffect(() => {
+ if (!selectedAsset) return;
+
+ const interval = setInterval(() => {
+ fetchCandles(selectedAsset.symbol);
+ }, 15000);
+
+ return () => clearInterval(interval);
+ }, [selectedAsset, fetchCandles]);
+
+ const getSparklinePath = () => {
+ if (candles.length < 2) return '';
+
+ const width = 800;
+ const height = 100;
+ const padding = 5;
+
+ const prices = candles.map((c) => c.close);
+ const minPrice = Math.min(...prices);
+ const maxPrice = Math.max(...prices);
+ const priceRange = maxPrice - minPrice || 1;
+
+ const points = candles.map((candle, index) => {
+ const x =
+ padding + (index / (candles.length - 1)) * (width - 2 * padding);
+ const y =
+ height -
+ padding -
+ ((candle.close - minPrice) / priceRange) * (height - 2 * padding);
+ return `${x},${y}`;
+ });
+
+ return `M ${points.join(' L ')}`;
+ };
+
+ const getPercentageChange = () => {
+ if (candles.length < 2) return 0;
+ const firstPrice = candles[0].close;
+ const lastPrice = candles[candles.length - 1].close;
+ return ((lastPrice - firstPrice) / firstPrice) * 100;
+ };
+
+ const formatVolume = (volume: string): string => {
+ const num = parseFloat(volume);
+ if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
+ if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
+ if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
+ return `$${num.toFixed(2)}`;
+ };
+
+ const formatPrice = (value: number | string): string => {
+ const num = typeof value === 'string' ? parseFloat(value) : value;
+ if (Math.abs(num) < 1 && Math.abs(num) > 0) {
+ return num.toFixed(5);
+ }
+ return num.toLocaleString('en-US', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ });
+ };
+
+ const formatNumber = (value: string, decimals: number = 2): string => {
+ const num = parseFloat(value);
+ return num.toLocaleString('en-US', {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ });
+ };
+
+ const isMobile = useIsMobile();
+ const percentChange = getPercentageChange();
+ const isPositive = percentChange >= 0;
+
+ // ----- Extract Position Data -----
+ let entryPrice: number | null = null;
+ let entryPercent: number | null = null;
+ let tpPrice: number | null = null;
+ let tpPercent: number | null = null;
+ let slPrice: number | null = null;
+ let slPercent: number | null = null;
+ const [entryTime, setEntryTime] = useState(null);
+
+ if (selectedAsset && currentPrice && userState?.assetPositions) {
+ // Find active position
+ const position = userState.assetPositions.find(
+ (p: any) => p.position.coin === selectedAsset.symbol
+ );
+ if (position) {
+ const rawEntry = parseFloat(position.position.entryPx);
+ if (!isNaN(rawEntry) && rawEntry > 0) {
+ entryPrice = rawEntry;
+ entryPercent = ((currentPrice - entryPrice) / entryPrice) * 100;
+ }
+ }
+ }
+
+ useEffect(() => {
+ let mounted = true;
+ const fetchEntryTime = async () => {
+ if (!accountAddress || !selectedAsset || !entryPrice) {
+ if (mounted) setEntryTime(null);
+ return;
+ }
+
+ try {
+ const fills = await getUserFills(accountAddress);
+ // Find the most recent fill for this coin that is on the same side as current position
+ // Actually, just finding the last fill for the coin is a good approximation for "entry"
+ // if we assume linear position building.
+ // Fills are usually returned latest first? API returns all fills.
+ const assetFills = fills.filter((f: any) => f.coin === selectedAsset.symbol);
+
+ if (assetFills.length > 0) {
+ // Sort descending by time
+ assetFills.sort((a: any, b: any) => b.time - a.time);
+ const lastFill = assetFills[0];
+ if (mounted) setEntryTime(lastFill.time);
+ }
+ } catch (e) {
+ console.error('Error fetching fills:', e);
+ }
+ };
+
+ fetchEntryTime();
+
+ return () => { mounted = false; };
+ }, [accountAddress, selectedAsset?.symbol, entryPrice]); // Re-run if entryPrice determined (position exists)
+
+ if (!selectedAsset) {
+ return (
+
+
+
+ Select an asset to view the price chart
+
+
+
+ );
+ }
+
+ /* Helper to format dates for X-axis */
+ const formatAxisTime = (timestamp: number) => {
+ return new Date(timestamp).toLocaleTimeString('en-US', {
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+ };
+
+ /* Helper to get price levels for Y-axis */
+ const getPriceLevels = () => {
+ if (candles.length < 2) return null;
+ const prices = candles.map((c) => c.close);
+ const min = Math.min(...prices);
+ const max = Math.max(...prices);
+ const mid = (min + max) / 2;
+ return { min, mid, max };
+ };
+
+ const priceLevels = getPriceLevels();
+
+ // ----- Helper to Calculate Y Position for Custom Lines -----
+ const getPriceY = (price: number): number | null => {
+ if (!priceLevels) return null;
+ const { min, max } = priceLevels;
+ if (price < min || price > max) return null; // Out of range
+
+ const height = 100;
+ const padding = 5;
+ const priceRange = max - min || 1;
+
+ // Same formula as Sparkline
+ return height - padding - ((price - min) / priceRange) * (height - 2 * padding);
+ };
+
+
+
+ if (selectedAsset && currentPrice && openOrders) {
+ // Filter orders for this asset
+ const assetOrders = openOrders.filter((o: any) => o.coin === selectedAsset.symbol);
+
+ // Determine TP/SL logic (simplified from PositionsCard)
+ // TP: Order in opposite direction that takes profit
+ // SL: Order in opposite direction that stops loss/liquidation
+ // We need to know if we are Long or Short from the position
+ const position = userState?.assetPositions?.find(
+ (p: any) => p.position.coin === selectedAsset.symbol
+ );
+
+ if (position) {
+ const size = parseFloat(position.position.szi);
+ const isLong = size > 0;
+
+ // TP/SL are typically reduce-only orders
+ // For LONG: TP > Entry (Sell High), SL < Entry (Sell Low)
+ // For SHORT: TP < Entry (Buy Low), SL > Entry (Buy High)
+ // But effectively we just look for orders.
+
+ const tps: any[] = [];
+ const sls: any[] = [];
+
+ assetOrders.forEach((order: any) => {
+ const isReduceOnly = order.reduceOnly;
+ const isClosingOrder = (isLong && order.side === 'A') || // Long -> Sell (Ask)
+ (!isLong && order.side === 'B'); // Short -> Buy (Bid)
+
+ if (isClosingOrder && isReduceOnly) {
+ // Use triggerPx from API (same as PositionsCard fix)
+ const triggerPx = parseFloat(order.triggerPx || order.trigger?.triggerPx || order.triggerCondition?.triggerPx || '0');
+ const limitPx = parseFloat(order.limitPx || '0');
+ const px = triggerPx > 0 ? triggerPx : limitPx;
+
+ // Classify using orderType from API (same as PositionsCard fix)
+ if (order.orderType && order.orderType.toLowerCase().includes('take profit')) {
+ tps.push({ price: px });
+ } else if (order.orderType && order.orderType.toLowerCase().includes('stop')) {
+ sls.push({ price: px });
+ } else {
+ // Fallback to price logic
+ if (isLong) {
+ if (px > (entryPrice || 0)) tps.push({ price: px });
+ else sls.push({ price: px });
+ } else {
+ if (px < (entryPrice || 0)) tps.push({ price: px });
+ else sls.push({ price: px });
+ }
+ }
+ }
+ });
+
+ // Sort to find closest? or display all? user said "the take profits" (plural? or singular logic)
+ // "the take profits (if in range)". Let's pick the closest one for now or loop?
+ // Let's loop and render all if possible, or just the first/closest.
+ // User asked for "the take profits" implying potentially multiple.
+ // Layout-wise, let's just show the closest TP and closest SL to keep chart clean.
+
+ if (tps.length > 0) {
+ const closestTp = isLong ? tps.sort((a, b) => a.price - b.price)[0] : tps.sort((a, b) => b.price - a.price)[0];
+ tpPrice = closestTp.price;
+ tpPercent = ((tpPrice! - currentPrice) / currentPrice) * 100;
+ }
+ if (sls.length > 0) {
+ const closestSl = isLong ? sls.sort((a, b) => b.price - a.price)[0] : sls.sort((a, b) => a.price - b.price)[0];
+ slPrice = closestSl.price;
+ slPercent = ((slPrice! - currentPrice) / currentPrice) * 100;
+ }
+ }
+ }
+
+ const entryY = entryPrice ? getPriceY(entryPrice) : null;
+ const tpY = tpPrice ? getPriceY(tpPrice) : null;
+ const slY = slPrice ? getPriceY(slPrice) : null;
+
+ return (
+
+
+ {/* Header with current price and market data */}
+
+ {/* --- MOBILE LAYOUT (< 1024px) --- */}
+ {isMobile ? (
+
+ {/* Top Row: Symbol (Left) vs Price (Right) */}
+
+ {/* Left: Symbol & Badge */}
+
+
+
+
+ {selectedAsset.symbol}
+
+
+ {selectedAsset.maxLeverage}x
+
+
+
+ Price (12H)
+
+
+
+ {/* Right: Price & Change */}
+
+ {currentPrice !== null && (
+ <>
+
+ ${formatPrice(currentPrice)}
+
+
+ {isPositive ? '+' : ''}
+ {percentChange.toFixed(2)}%
+
+ >
+ )}
+
+
+
+ {/* Bottom Row: 3x2 Data Grid (Matching Desktop) */}
+ {marketData && (
+
+
+
+ Mark
+
+
+ ${formatPrice(marketData.markPx)}
+
+
+
+
+
+ 24H Change
+
+
+ {formatPrice(
+ String(
+ parseFloat(marketData.markPx) -
+ parseFloat(marketData.prevDayPx)
+ )
+ )}{' '}
+ / {percentChange.toFixed(2)}%
+
+
+
+
+
+ 24H Volume
+
+
+ {formatVolume(marketData.dayNtlVlm)}
+
+
+
+
+
+ Oracle
+
+
+ ${formatPrice(marketData.oraclePx)}
+
+
+
+
+
+ Open Interest
+
+
+ ${formatNumber(marketData.openInterest, 2)}
+
+
+
+
+
+ Funding
+
+
= 0 ? 'text-green-500' : 'text-red-500'}`}
+ >
+ {(parseFloat(marketData.funding) * 100).toFixed(4)}%
+
+
+
+ )}
+
+ ) : (
+ /* --- DESKTOP LAYOUT (>= 1024px) --- */
+
+ {/* Left: Logo + Symbol + Price + Change - All on same line */}
+
+
+
{selectedAsset.symbol}
+ {currentPrice !== null && (
+
+
+ ${formatPrice(currentPrice)}
+
+
+ {isPositive ? '+' : ''}
+ {percentChange.toFixed(2)}%
+
+
+ )}
+
+
+ {/* Right: Compact Market Data - Grid 3x2 */}
+ {marketData && (
+
+
+
+ Mark
+
+
+ ${formatPrice(marketData.markPx)}
+
+
+
+
+
+ 24H Change
+
+
+ {formatPrice(
+ String(
+ parseFloat(marketData.markPx) -
+ parseFloat(marketData.prevDayPx)
+ )
+ )}{' '}
+ / {percentChange.toFixed(2)}%
+
+
+
+
+
+ 24H Volume
+
+
+ {formatVolume(marketData.dayNtlVlm)}
+
+
+
+
+
+ Oracle
+
+
+ ${formatPrice(marketData.oraclePx)}
+
+
+
+
+
+ Open Interest
+
+
+ ${formatNumber(marketData.openInterest, 2)}
+
+
+
+
+
+ Funding
+
+
= 0 ? 'text-green-500' : 'text-red-500'}`}
+ >
+ {(parseFloat(marketData.funding) * 100).toFixed(4)}%
+
+
+
+ )}
+
+ )}
+
+
+ {/* Sparkline Chart */}
+ {error ? (
+
+ Error: {error}
+
+ ) : candles.length < 2 ? (
+
+ {isLoading ? 'Loading chart data...' : 'No data available'}
+
+ ) : (
+
+
{
+ /* Existing hover logic... */
+ const rect = e.currentTarget.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const svgWidth = rect.width;
+ const dataIndex = Math.round(
+ (x / svgWidth) * (candles.length - 1)
+ );
+ if (dataIndex >= 0 && dataIndex < candles.length) {
+ const candle = candles[dataIndex];
+ const prices = candles.map((c) => c.close);
+ const minPrice = Math.min(...prices);
+ const maxPrice = Math.max(...prices);
+ const normalizedY =
+ ((candle.close - minPrice) / (maxPrice - minPrice)) * 100;
+ setHoverData({
+ x: (dataIndex / (candles.length - 1)) * 100,
+ y: 100 - normalizedY,
+ price: candle.close,
+ time: candle.time,
+ });
+ }
+ }}
+ onMouseLeave={() => setHoverData(null)}
+ >
+
+
+ {/* Position Labels (Absolute positioned on top of SVG) */}
+
+ {/* Hover Tooltip */}
+ {hoverData && (
+
+
+
+ ${formatPrice(hoverData.price)}
+
+
+ {new Date(hoverData.time).toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+
+
+ )}
+
+ {/* Entry Dot (HTML Overlay for perfect roundness) */}
+ {entryY !== null && entryTime && candles.length > 1 && (() => {
+ const firstTime = candles[0].time;
+ const lastTime = candles[candles.length - 1].time;
+ if (entryTime >= firstTime && entryTime <= lastTime) {
+ const timeRange = lastTime - firstTime;
+ const timeRatio = (entryTime - firstTime) / timeRange;
+ const leftPercent = timeRatio * 100;
+ const topPercent = (entryY / 100) * 100;
+
+ return (
+
+ {/* Pulsing effect - White Ring Ripple */}
+
+
+ );
+ }
+ return null;
+ })()}
+
+ {/* TP Label */}
+ {tpY !== null && tpPercent !== null && (
+
+ TP: ${formatPrice(tpPrice!)}
+
+ ({tpPercent >= 0 ? '+' : ''}{tpPercent.toFixed(2)}%)
+
+
+ )}
+
+ {/* SL Label */}
+ {slY !== null && slPercent !== null && (
+
+ SL: ${formatPrice(slPrice!)}
+
+ ({slPercent >= 0 ? '+' : ''}{slPercent.toFixed(2)}%)
+
+
+ )}
+
+
+
+ {/* Y-Axis (Right) */}
+ {priceLevels && (
+
+
+ ${formatPrice(priceLevels.max)}
+
+
+ ${formatPrice(priceLevels.mid)}
+
+
+ ${formatPrice(priceLevels.min)}
+
+
+ )}
+
+
+ {/* X-Axis (Bottom) */}
+
+ {formatAxisTime(candles[0].time)}
+
+ {formatAxisTime(candles[Math.floor(candles.length / 2)].time)}
+
+ {formatAxisTime(candles[candles.length - 1].time)}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/apps/perps/components/StatusBanner.tsx b/src/apps/perps/components/StatusBanner.tsx
new file mode 100644
index 00000000..d48abbcc
--- /dev/null
+++ b/src/apps/perps/components/StatusBanner.tsx
@@ -0,0 +1,74 @@
+import { Badge } from './ui/badge';
+import { AlertCircle, CheckCircle2, HelpCircle } from 'lucide-react';
+import { cn } from '../lib/utils';
+
+type Status = 'unknown' | 'not-setup' | 'setup';
+
+interface StatusBannerProps {
+ status: Status;
+ onSetup?: () => void;
+ isSettingUp?: boolean;
+}
+
+export function StatusBanner({
+ status,
+ onSetup,
+ isSettingUp,
+}: StatusBannerProps) {
+ const statusConfig = {
+ unknown: {
+ icon: HelpCircle,
+ label: 'Unknown',
+ color: 'text-muted-foreground',
+ bgColor: 'bg-muted',
+ description: 'Connect your wallet to check status',
+ },
+ 'not-setup': {
+ icon: AlertCircle,
+ label: 'Not Set Up',
+ color: 'text-warning',
+ bgColor: 'bg-warning/10 border-warning/30',
+ description: 'Setup required to use Hyperliquid',
+ },
+ setup: {
+ icon: CheckCircle2,
+ label: 'Connected',
+ color: 'text-success',
+ bgColor: 'bg-success/10 border-success/30',
+ description: 'Ready to trade',
+ },
+ };
+
+ const config = statusConfig[status];
+ const Icon = config.icon;
+
+ return (
+
+
+
+
+
+
+ Hyperliquid Status:
+
+ {config.label}
+
+
+
+ {config.description}
+
+
+
+ {status === 'not-setup' && onSetup && (
+
+ )}
+
+
+ );
+}
diff --git a/src/apps/perps/components/TokenIcon.tsx b/src/apps/perps/components/TokenIcon.tsx
new file mode 100644
index 00000000..c70b0673
--- /dev/null
+++ b/src/apps/perps/components/TokenIcon.tsx
@@ -0,0 +1,68 @@
+import { useState } from 'react';
+import { Loader2 } from 'lucide-react';
+
+interface TokenIconProps {
+ symbol: string;
+ size?: number;
+ className?: string;
+}
+
+export function TokenIcon({
+ symbol,
+ size = 24,
+ className = '',
+}: TokenIconProps) {
+ const [error, setError] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ // Logic to clean symbol (e.g., kPEPE -> PEPE) for file lookup
+ let fileSymbol = symbol;
+ if (symbol.startsWith('k') && symbol.length > 2 && symbol !== 'kBENJI') {
+ fileSymbol = symbol.slice(1);
+ }
+ // Handle specific edge cases if known, e.g. HYPE -> HYPE.svg (standard)
+
+ const iconUrl = `https://app.hyperliquid.xyz/coins/${fileSymbol}.svg`;
+
+ if (error) {
+ return (
+
+
+ {symbol.slice(0, 1)}
+
+
+ );
+ }
+
+ return (
+
+ {loading && (
+
+
+
+ )}
+

setLoading(false)}
+ onError={() => {
+ setError(true);
+ setLoading(false);
+ }}
+ style={{ width: size, height: size, objectFit: 'cover' }}
+ />
+
+ );
+}
diff --git a/src/apps/perps/components/TradeForm.tsx b/src/apps/perps/components/TradeForm.tsx
new file mode 100644
index 00000000..2f5bf0fe
--- /dev/null
+++ b/src/apps/perps/components/TradeForm.tsx
@@ -0,0 +1,1247 @@
+import { useState, useEffect, useRef } from 'react';
+import { useForm, useFieldArray } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import * as z from 'zod';
+import { Button } from './ui/button';
+import { Card } from './ui/card';
+import { Input } from './ui/input';
+import { Label } from './ui/label';
+import { Switch } from './ui/switch';
+import { Plus, X, Loader2 } from 'lucide-react';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from './ui/select';
+import { Slider } from './ui/slider';
+import { toast } from 'sonner';
+import {
+ getAgentWallet,
+ getImportedAccount,
+} from '../lib/hyperliquid/keystore';
+import { getMarkPrice, getUserState } from '../lib/hyperliquid/client';
+import { useWalletClient, useAccount } from 'wagmi';
+import { useHyperliquid } from '../hooks/useHyperliquid';
+import { computeSizeUSD, roundToSzDecimals } from '../lib/hyperliquid/order';
+import { TokenIcon } from './TokenIcon';
+import {
+ placeMarketOrderAgent,
+ placeLimitOrderAgent,
+ placeTriggerOrderAgent,
+ updateLeverageAgent,
+} from '../lib/hyperliquid/sdk';
+import { parsePositionForSymbol } from '../lib/hyperliquid/parsers';
+import { PasteStrategyButton } from './PasteStrategyButton';
+import type { AssetInfo, UserState } from '../lib/hyperliquid/types';
+import { BUILDER_ADDRESS, BUILDER_FEE_ORDER, BUILDER_FEE_APPROVAL } from '../lib/hyperliquid/builder';
+import { approveBuilderFeeSDK } from '../lib/hyperliquid/sdk';
+
+const tradeSchema = z
+ .object({
+ side: z.enum(['long', 'short']),
+ entryPrice: z.number().positive().optional(),
+ amountUSD: z
+ .number()
+ .positive()
+ .min(10, { message: 'Amount must be at least 10 USDC' }),
+ leverage: z.number().min(1).max(50),
+ marginMode: z.enum(['cross', 'isolated']).default('cross'),
+ stopLoss: z
+ .object({
+ price: z.number().nonnegative().optional(),
+ distance: z.number().optional(),
+ })
+ .optional(),
+ takeProfits: z
+ .array(
+ z.object({
+ price: z.number().nonnegative(),
+ ratio: z.number().min(0).max(100),
+ distance: z.number().optional(),
+ })
+ )
+ .optional(),
+ })
+ .refine((data) => {
+ // Basic validation logic
+ return true;
+ });
+
+type TradeFormData = z.infer;
+
+interface TradeFormProps {
+ selectedAsset: EnhancedAsset | null;
+ onTradeComplete?: () => void;
+ onTickerChange?: (ticker: string) => void;
+ prefilledData?: {
+ side?: 'long' | 'short';
+ entryPrice?: number;
+ stopLoss?: number;
+ takeProfits?: string;
+ };
+ userState?: UserState | null;
+}
+
+export function TradeForm({
+ selectedAsset,
+ onTradeComplete,
+ onTickerChange,
+ prefilledData,
+ userState,
+}: TradeFormProps) {
+ const { address: masterAddress } = useHyperliquid();
+ const { address: connectedAddress } = useAccount();
+ const [isMarketOrder, setIsMarketOrder] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [marketPrice, setMarketPrice] = useState(null);
+ const [minUSD, setMinUSD] = useState(null);
+ const isStrategyPasteRef = useRef(false);
+
+ const {
+ register,
+ handleSubmit,
+ control,
+ formState: { errors },
+ watch,
+ setValue,
+ getValues, // Added getValues
+ } = useForm({
+ resolver: zodResolver(tradeSchema),
+ mode: 'onChange',
+ defaultValues: {
+ side: 'long',
+ amountUSD: 25,
+ leverage: selectedAsset ? Math.floor(selectedAsset.maxLeverage / 2) : 1,
+ marginMode: 'cross',
+ takeProfits: [], // Initialize as empty array
+ stopLoss: undefined,
+ },
+ });
+
+ const {
+ fields: tpFields,
+ append: appendTp,
+ remove: removeTp,
+ } = useFieldArray({
+ control,
+ name: 'takeProfits',
+ });
+
+ const entryPrice = watch('entryPrice');
+ const stopLoss = watch('stopLoss');
+ const takeProfits = watch('takeProfits');
+ const marginMode = watch('marginMode');
+
+ // Helper to calculate distributed ratios
+ const getDistributedRatios = (count: number) => {
+ if (count <= 0) return [];
+ const base = Math.floor(100 / count);
+ const remainder = 100 % count;
+ return Array(count)
+ .fill(base)
+ .map((val, i) => (i < remainder ? val + 1 : val));
+ };
+
+ // Handle adding TP with auto-redistribution
+ const handleAddTp = () => {
+ const newCount = tpFields.length + 1;
+ const ratios = getDistributedRatios(newCount);
+
+ // Convert existing fields to new ratios
+ // We need to flush updates to existing fields first
+ const currentValues = getValues('takeProfits') || [];
+ const updatedValues = currentValues.map((tp, i) => ({
+ ...tp,
+ ratio: ratios[i],
+ }));
+
+ // Add new field with its calculated ratio
+ updatedValues.push({
+ price: 0,
+ ratio: ratios[newCount - 1],
+ distance: 0,
+ });
+
+ // Replace all with new values
+ setValue('takeProfits', updatedValues);
+ };
+
+ // Handle removing TP with auto-redistribution
+ const handleRemoveTp = (index: number) => {
+ const currentValues = getValues('takeProfits') || [];
+ const keptValues = currentValues.filter((_, i) => i !== index);
+
+ if (keptValues.length > 0) {
+ const ratios = getDistributedRatios(keptValues.length);
+ const updatedValues = keptValues.map((tp, i) => ({
+ ...tp,
+ ratio: ratios[i],
+ }));
+ setValue('takeProfits', updatedValues);
+ } else {
+ setValue('takeProfits', []);
+ }
+ };
+
+ // Helper to calculate distance from price (Absolute %)
+ const calculateDistance = (targetPrice: number, currentEntry: number) => {
+ if (!currentEntry) return 0;
+ const dist = Math.abs((targetPrice - currentEntry) / currentEntry) * 100;
+ return parseFloat(dist.toFixed(2));
+ };
+
+ // Helper to calculate price from distance
+ const calculatePriceFromDistance = (
+ distancePercent: number,
+ currentEntry: number,
+ isLong: boolean,
+ isStopLoss: boolean
+ ) => {
+ if (!currentEntry) return 0;
+ const change = (distancePercent / 100) * currentEntry;
+ if (isStopLoss) {
+ return isLong ? currentEntry - change : currentEntry + change;
+ }
+ return isLong ? currentEntry + change : currentEntry - change;
+ };
+
+ // Update leverage when asset changes if not set
+ useEffect(() => {
+ if (selectedAsset) {
+ setValue('leverage', Math.floor(selectedAsset.maxLeverage / 2));
+ }
+ }, [selectedAsset, setValue]);
+
+ const side = watch('side');
+ const amountUSD = watch('amountUSD');
+ const leverage = watch('leverage');
+
+ // Fetch market price for minimum calculation
+ useEffect(() => {
+ if (selectedAsset && isMarketOrder) {
+ getMarkPrice(selectedAsset.symbol).then((price) => {
+ if (price) setMarketPrice(price);
+ });
+ } else if (selectedAsset && !isMarketOrder) {
+ if (isStrategyPasteRef.current) {
+ // Skip overwriting entry price if it came from a strategy paste
+ isStrategyPasteRef.current = false;
+ } else {
+ // Pre-fill entry price with current asset price for Limit orders
+ setValue('entryPrice', selectedAsset.price);
+ }
+ }
+ }, [selectedAsset, isMarketOrder, setValue]);
+
+ // Calculate minimum USD required
+ useEffect(() => {
+ if (!selectedAsset) return;
+
+ const price = marketPrice || 1; // Use 1 as fallback for estimation
+ const minSize = Math.pow(10, -selectedAsset.szDecimals);
+ const minRequired = (minSize * price) / (leverage || 1);
+ setMinUSD(minRequired);
+ }, [selectedAsset, marketPrice, leverage]);
+
+ // Apply prefilled data when it changes
+ useEffect(() => {
+ if (prefilledData) {
+ if (prefilledData.side) {
+ setValue('side', prefilledData.side);
+ }
+ const ep = prefilledData.entryPrice || entryPrice || 0;
+ const isLong = (prefilledData.side || side) === 'long';
+
+ if (prefilledData.entryPrice) {
+ setValue('entryPrice', prefilledData.entryPrice);
+ setIsMarketOrder(false);
+ }
+ if (prefilledData.stopLoss) {
+ setValue('stopLoss', {
+ price: prefilledData.stopLoss,
+ distance: calculateDistance(prefilledData.stopLoss, ep),
+ });
+ }
+ if (prefilledData.takeProfits) {
+ const tps = prefilledData.takeProfits
+ .split(',')
+ .map((s) => parseFloat(s.trim()))
+ .filter((n) => !isNaN(n));
+ const ratio = tps.length > 0 ? Math.floor(100 / tps.length) : 0;
+ setValue(
+ 'takeProfits',
+ tps.map((p) => ({
+ price: p,
+ ratio: ratio,
+ distance: calculateDistance(p, ep),
+ }))
+ );
+ }
+ }
+ }, [prefilledData, setValue, entryPrice, side]);
+
+ // Handle pasted strategy
+ const handleStrategyPasted = (strategy: {
+ ticker: string;
+ side: 'long' | 'short';
+ entryPrice: number;
+ stopLoss: number;
+ takeProfits: string;
+ }) => {
+ // Notify parent to switch ticker
+ if (onTickerChange) {
+ onTickerChange(strategy.ticker);
+ }
+
+ // Populate form fields
+ setValue('side', strategy.side);
+ setValue('entryPrice', strategy.entryPrice);
+
+ // Transform SL
+ if (strategy.stopLoss) {
+ setValue('stopLoss', {
+ price: strategy.stopLoss,
+ distance: calculateDistance(
+ strategy.stopLoss,
+ strategy.entryPrice
+ ),
+ });
+ }
+
+ // Transform TP
+ if (strategy.takeProfits) {
+ const tps = strategy.takeProfits
+ .split(',')
+ .map((s) => parseFloat(s.trim()))
+ .filter((n) => !isNaN(n));
+ const baseRatio = tps.length > 0 ? Math.floor(100 / tps.length) : 0;
+ const remainder = tps.length > 0 ? 100 - baseRatio * tps.length : 0;
+
+ setValue(
+ 'takeProfits',
+ tps.map((p, index) => ({
+ price: p,
+ ratio: index === 0 ? baseRatio + remainder : baseRatio,
+ distance: calculateDistance(
+ p,
+ strategy.entryPrice
+ ),
+ }))
+ );
+ }
+
+ // If we are currently in market mode, switching to limit mode will trigger the useEffect
+ // that sets entry price. We need to flag this to avoid overwriting the strategy price.
+ if (isMarketOrder || (selectedAsset && selectedAsset.symbol !== strategy.ticker)) {
+ isStrategyPasteRef.current = true;
+ }
+
+ setIsMarketOrder(false); // Always use limit order for pasted strategies
+ };
+
+ // Check if amount is below minimum
+ const isBelowMinimum = minUSD !== null && amountUSD > 0 && amountUSD < minUSD;
+
+ // Verify position was opened after trade (check master wallet, not agent)
+ const verifyPositionOpened = async (
+ symbol: string,
+ masterWalletAddress: string,
+ maxAttempts = 5,
+ delayMs = 1000
+ ): Promise => {
+ for (let i = 0; i < maxAttempts; i++) {
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+
+ const state = await getUserState(masterWalletAddress);
+ if (!state) continue;
+
+ const position = parsePositionForSymbol(state, symbol);
+ if (position && position.size > 0) {
+ return true; // Position found!
+ }
+ }
+ return false; // Position not found after all attempts
+ };
+
+ const onSubmit = async (data: TradeFormData) => {
+ console.log('Form submitted with data:', data);
+ const toastId = toast.loading('Placing order...');
+
+ if (!selectedAsset) {
+ toast.error('Please select an asset', { id: toastId });
+ return;
+ }
+
+ if (!masterAddress) {
+ toast.error('Please connect your wallet', { id: toastId });
+ return;
+ }
+
+ let privateKey: string | undefined;
+ let signingAddress: string | undefined = masterAddress;
+
+ // 1. Check for Imported Account (Priority)
+ // Only use imported account if it's unlocked and available
+ const imported = getImportedAccount();
+
+ console.log('[TradeForm] Wallet Selection Debug:', {
+ hasImportedKey: !!imported,
+ masterAddress,
+ connectedAddress
+ });
+
+ if (imported) {
+ // Unlocked and ready
+ privateKey = imported.privateKey;
+ signingAddress = imported.accountAddress;
+ console.log('DEBUG: Using imported account', { signingAddress });
+ }
+ // 2. Fallback to Agent Wallet linked to connected wallet
+ else {
+ console.log('DEBUG: Fallback to agent wallet');
+ const agent = await getAgentWallet(masterAddress);
+ if (agent?.approved) {
+ if (!agent.builderApproved) {
+ toast.error('PillarX Approval Required', {
+ description: 'Please go to Settings > Perps Account and approve PillarX to start trading.',
+ duration: 5000,
+ });
+ setIsSubmitting(false); // Reset loading state
+ return;
+ }
+ privateKey = agent.privateKey;
+ signingAddress = masterAddress; // Agent trades on behalf of master
+ }
+ }
+
+ if (!privateKey) {
+ console.log('DEBUG: No private key found');
+ toast.error(
+ 'No active signing wallet found. Please create an agent or import an account.',
+ { id: toastId }
+ );
+ return;
+ }
+
+ // Only apply builder fee if the signing address matches the connected wallet (Master)
+ // If using an imported account that is different from the connected wallet, we cannot approve the builder fee
+ // because the connected wallet (Master) cannot sign for the imported account.
+ const useBuilderFee = signingAddress === masterAddress;
+
+ setIsSubmitting(true);
+ try {
+ // Get entry price
+ let entryPrice = data.entryPrice;
+ if (isMarketOrder || !entryPrice) {
+ // toast.info('Fetching market price...');
+ entryPrice = await getMarkPrice(selectedAsset.symbol);
+ if (!entryPrice) {
+ throw new Error('Failed to fetch market price');
+ }
+ }
+
+ // Calculate size
+ const size = computeSizeUSD(
+ data.amountUSD,
+ data.leverage,
+ entryPrice,
+ selectedAsset.szDecimals
+ );
+
+ if (size <= 0) {
+ const minSize = Math.pow(10, -selectedAsset.szDecimals);
+ const minRequired = (minSize * entryPrice) / data.leverage;
+ toast.error(`Amount too small for ${selectedAsset.symbol}`, {
+ id: toastId,
+ description: `Minimum required: $${minRequired.toFixed(2)} at ${data.leverage}x leverage`,
+ });
+ return;
+ }
+
+ // Update leverage and margin mode before placing orders
+ try {
+ await updateLeverageAgent(privateKey as `0x${string}`, {
+ coinId: selectedAsset.id,
+ leverage: data.leverage,
+ isCross: data.marginMode === 'cross',
+ });
+ console.log(
+ `[TradeForm] Updated leverage: ${data.leverage}x ${data.marginMode}`
+ );
+ } catch (leverageError: any) {
+ console.error('[TradeForm] Failed to update leverage:', leverageError);
+
+ const errorMessage = leverageError.message || '';
+ if (errorMessage.includes('does not exist')) {
+ toast.error('Account not initialized', {
+ id: toastId,
+ description: 'Please deposit funds into your Hyperliquid account first to enable trading features.',
+ duration: 5000,
+ });
+ } else {
+ toast.error('Failed to set leverage/margin mode', {
+ id: toastId,
+ description: errorMessage || 'Please try again',
+ });
+ }
+ return;
+ }
+
+ // Place entry order via SDK
+ // toast.info('Placing entry order...');
+
+ // Determine builder fee parameters
+ // 1. We are NOT using an imported account (i.e. we are using the Agent)
+ // 2. The signing address matches the master address (Agent Flow)
+ // If we are using an imported account, we NEVER apply builder fees for now to avoid the "approve" loop issue.
+ // Even if imported address match master, we treat it as "User Trading" not "Agent Trading".
+ const useBuilderFee = !imported && (signingAddress === masterAddress);
+
+ console.log('[TradeForm] Builder Fee Debug:', {
+ masterAddress,
+ signingAddress,
+ useBuilderFee,
+ isImported: !!imported
+ });
+
+
+ if (isMarketOrder) {
+ await placeMarketOrderAgent(privateKey as `0x${string}`, {
+ coinId: selectedAsset.id,
+ isBuy: data.side === 'long',
+ size,
+ currentPrice: entryPrice,
+ builder: useBuilderFee ? { b: BUILDER_ADDRESS, f: BUILDER_FEE_ORDER } : undefined,
+ });
+ } else {
+ await placeLimitOrderAgent(privateKey as `0x${string}`, {
+ coinId: selectedAsset.id,
+ isBuy: data.side === 'long',
+ size,
+ limitPrice: entryPrice,
+ reduceOnly: false,
+ builder: useBuilderFee ? { b: BUILDER_ADDRESS, f: BUILDER_FEE_ORDER } : undefined,
+ });
+ }
+
+ // Place stop loss if provided
+ if (data.stopLoss && data.stopLoss.price) {
+ // toast.info('Placing stop loss...');
+
+ // Calculate limit price with slippage buffer
+ // For Long: SL triggers below entry, so limit should be even lower (0.99x)
+ // For Short: SL triggers above entry, so limit should be even higher (1.01x)
+ const slLimitPrice =
+ data.side === 'long'
+ ? data.stopLoss.price * 0.99
+ : data.stopLoss.price * 1.01;
+
+ await placeTriggerOrderAgent(privateKey as `0x${string}`, {
+ coinId: selectedAsset.id,
+ isBuy: data.side === 'short', // Opposite side for reduce-only
+ size,
+ triggerPrice: data.stopLoss.price,
+ limitPrice: slLimitPrice,
+ tpsl: 'sl',
+ reduceOnly: true,
+ builder: useBuilderFee ? { b: BUILDER_ADDRESS, f: BUILDER_FEE_ORDER } : undefined,
+ });
+ }
+
+ // Place take profits if provided
+ if (data.takeProfits && data.takeProfits.length > 0) {
+ const tps = data.takeProfits;
+
+ // Validate total ratio
+ const totalRatio = tps.reduce((sum, tp) => sum + (tp.ratio || 0), 0);
+ if (Math.abs(totalRatio - 100) > 0.1) {
+ toast.error(
+ `Total Take Profit ratio must be 100% (Currently: ${totalRatio.toFixed(0)}%)`,
+ { id: toastId }
+ );
+ setIsSubmitting(false);
+ return;
+ }
+
+ for (let i = 0; i < tps.length; i++) {
+ const tp = tps[i];
+ if (!tp.price || !tp.ratio) continue;
+
+ // toast.info(`Placing take profit ${i + 1}/${tps.length}...`);
+
+ const rawTpSize = size * (tp.ratio / 100);
+ const tpSize = roundToSzDecimals(rawTpSize, selectedAsset.szDecimals);
+
+ if (tpSize <= 0) continue;
+
+ // Calculate limit price with slippage buffer
+ // For Long: TP triggers above entry, so limit should be slightly lower (1.01x is generous)
+ // For Short: TP triggers below entry, so limit should be slightly higher (0.99x)
+ const tpLimitPrice =
+ data.side === 'long' ? tp.price * 0.99 : tp.price * 1.01;
+
+ await placeTriggerOrderAgent(privateKey as `0x${string}`, {
+ coinId: selectedAsset.id,
+ isBuy: data.side === 'short', // Opposite side for reduce-only
+ size: tpSize,
+ triggerPrice: tp.price,
+ limitPrice: tpLimitPrice,
+ tpsl: 'tp',
+ reduceOnly: true,
+ builder: useBuilderFee ? { b: BUILDER_ADDRESS, f: BUILDER_FEE_ORDER } : undefined,
+ });
+ }
+ }
+
+ toast.success('Trade placed successfully!', {
+ id: toastId,
+ description: `${data.side.toUpperCase()} ${size} ${selectedAsset.symbol}`,
+ });
+
+ // Verify position was opened (check signing wallet)
+ if (signingAddress) {
+ toast.info('Verifying position...', { id: 'verify-position' });
+
+ const positionOpened = await verifyPositionOpened(
+ selectedAsset.symbol,
+ signingAddress
+ );
+
+ if (positionOpened) {
+ toast.success('Position confirmed on exchange', {
+ id: 'verify-position',
+ });
+ onTradeComplete?.();
+ } else {
+ toast.warning('Position not found on exchange', {
+ id: 'verify-position',
+ description:
+ 'The order was submitted but position is not visible yet. Check your orders manually.',
+ duration: 8000,
+ });
+ onTradeComplete?.(); // Still call this to refresh UI
+ }
+ } else {
+ onTradeComplete?.(); // No signing address, still refresh
+ }
+ } catch (error: any) {
+ console.error('Trade error:', error);
+
+ // Handle "Builder fee has not been approved"
+ // We only attempt to auto-approve if we are using an Imported Account AND it matches the signing address.
+ // If we are using Agent (fallback), we cannot auto-approve because we don't have the Master key.
+ if (
+ useBuilderFee &&
+ (error?.message?.includes('Builder fee has not been approved') ||
+ error?.response?.data?.includes('Builder fee has not been approved'))
+ ) {
+ // If useBuilderFee is true, it means signingAddress === masterAddress
+
+ // Check if we are using an imported account that effectively IS the master
+ const imported = getImportedAccount();
+ if (imported && imported.accountAddress.toLowerCase() === masterAddress?.toLowerCase()) {
+ try {
+ toast.info('Approving Builder Fee...', {
+ id: toastId,
+ description: 'One-time approval required for trading.',
+ });
+
+ await approveBuilderFeeSDK(
+ imported.privateKey,
+ BUILDER_ADDRESS,
+ BUILDER_FEE_APPROVAL
+ );
+
+ toast.success('Builder Fee Approved!', {
+ id: toastId,
+ description: 'Please try placing your order again.',
+ duration: 5000,
+ });
+ return; // Exit without showing generic error
+ } catch (approvalError: any) {
+ console.error('Failed to auto-approve builder fee:', approvalError);
+ toast.error('Failed to approve builder fee', {
+ id: toastId,
+ description: approvalError.message,
+ });
+ return;
+ }
+ } else {
+ // We are using Agent (or Master Wallet via some other means) but don't have the private key to approve.
+ toast.error('Builder Fee Not Approved', {
+ id: toastId,
+ description: 'Please go to Settings > Perps Account and approve PillarX.',
+ duration: 5000
+ });
+ return;
+ }
+ }
+
+
+ toast.error(error.message || 'Failed to place trade', { id: toastId });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (!selectedAsset) {
+ return (
+
+
+ Select an asset to start trading
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/apps/perps/components/TradeHistoryCard.tsx b/src/apps/perps/components/TradeHistoryCard.tsx
new file mode 100644
index 00000000..b0f73380
--- /dev/null
+++ b/src/apps/perps/components/TradeHistoryCard.tsx
@@ -0,0 +1,352 @@
+import { useState, useEffect, useCallback } from 'react';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '../components/ui/card';
+import { Skeleton } from '../components/ui/skeleton';
+import { Button } from '../components/ui/button';
+import { RefreshCw } from 'lucide-react';
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '../components/ui/collapsible';
+import { ChevronDown } from 'lucide-react';
+import { getUserFills } from '../lib/hyperliquid/client';
+import { TokenIcon } from './TokenIcon';
+import { formatDistanceToNow } from 'date-fns';
+
+interface TradeHistoryCardProps {
+ masterAddress: string;
+}
+
+interface Trade {
+ coin: string;
+ side: string;
+ px: string;
+ sz: string;
+ time: number;
+ closedPnl?: string;
+ fee?: string;
+ hash?: string;
+ tid?: number;
+}
+
+export function TradeHistoryCard({ masterAddress }: TradeHistoryCardProps) {
+ const [trades, setTrades] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isOpen, setIsOpen] = useState(false);
+ const [showAllHistory, setShowAllHistory] = useState(false);
+
+ const fetchTrades = useCallback(async () => {
+ if (!masterAddress) return;
+
+ console.log('[TradeHistory] Fetching trades for address:', masterAddress);
+ setIsLoading(true);
+ try {
+ const fills = await getUserFills(masterAddress);
+ console.log('[TradeHistory] Raw fills from API:', fills);
+ console.log('[TradeHistory] Number of fills:', fills?.length || 0);
+
+ // Process and sort trades by time (most recent first)
+ const processedTrades = fills
+ .map((fill: any) => ({
+ coin: fill.coin,
+ side: fill.side,
+ px: fill.px,
+ sz: fill.sz,
+ time: fill.time,
+ closedPnl: fill.closedPnl,
+ fee: fill.fee,
+ hash: fill.hash,
+ tid: fill.tid,
+ }))
+ .sort((a: Trade, b: Trade) => b.time - a.time);
+
+ console.log('[TradeHistory] Processed trades:', processedTrades);
+ console.log('[TradeHistory] Number of processed trades:', processedTrades.length);
+ setTrades(processedTrades);
+ } catch (error) {
+ console.error('Error fetching trades:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [masterAddress]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ fetchTrades();
+ const interval = setInterval(fetchTrades, 10000);
+ return () => clearInterval(interval);
+ }, [fetchTrades, isOpen]);
+
+ // Removed auto-collapse/expand logic to rely on manual user control
+
+ const formatNumber = (value: string | number, decimals: number = 2): string => {
+ if (!value) return '-';
+ return parseFloat(value.toString()).toFixed(decimals);
+ };
+
+ const formatPrice = (value: string | number): string => {
+ if (!value) return '-';
+ const val = parseFloat(value.toString());
+ if (val >= 1000) return val.toFixed(2);
+ if (val >= 1) return val.toFixed(4);
+ return val.toFixed(6);
+ };
+
+ const formatTime = (timestamp: number): string => {
+ try {
+ return new Date(timestamp).toLocaleString('en-GB', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false
+ });
+ } catch {
+ return new Date(timestamp).toLocaleString();
+ }
+ };
+
+ const calculateTradeValue = (price: string, size: string): string => {
+ const value = parseFloat(price) * parseFloat(size);
+ return value.toFixed(2);
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ Trade History
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+ ) : trades.length === 0 ? (
+
+ No trades found
+
+ ) : (
+
+ {/* Desktop Table View */}
+
+
+
+
+ | Coin |
+ Direction |
+ Price |
+ Time |
+ Size |
+ Value (USDC) |
+ Closed PnL |
+ Fee |
+
+
+
+ {trades
+ .filter(trade => {
+ if (showAllHistory) return true;
+ const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
+ return trade.time > thirtyDaysAgo;
+ })
+ .map((trade, index) => {
+ const isBuy = trade.side === 'B';
+ const pnl = parseFloat(trade.closedPnl || '0');
+ const isPnlPositive = pnl >= 0;
+
+ return (
+
+ |
+
+
+ {trade.coin}
+
+ |
+
+
+ {isBuy ? 'Buy' : 'Sell'}
+
+ |
+
+ ${formatPrice(trade.px)}
+ |
+
+ {formatTime(trade.time)}
+ |
+
+ {formatNumber(trade.sz, 4)}
+ |
+
+ ${calculateTradeValue(trade.px, trade.sz)}
+ |
+
+ {trade.closedPnl ? (
+
+ {isPnlPositive ? '+' : ''}${formatNumber(trade.closedPnl)}
+
+ ) : (
+ -
+ )}
+ |
+
+ ${formatNumber(trade.fee || '0')}
+ |
+
+ );
+ })}
+
+
+
+
+ {/* Mobile Card View */}
+
+ {trades
+ .filter(trade => {
+ if (showAllHistory) return true;
+ const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
+ return trade.time > thirtyDaysAgo;
+ })
+ .map((trade, index) => {
+ const isBuy = trade.side === 'B';
+ const pnl = parseFloat(trade.closedPnl || '0');
+ const isPnlPositive = pnl >= 0;
+
+ return (
+
+
+
+
+ {trade.coin}
+
+ {isBuy ? 'Buy' : 'Sell'}
+
+
+
+ {formatTime(trade.time)}
+
+
+
+
+
+
Price
+
${formatPrice(trade.px)}
+
+
+
Value
+
${calculateTradeValue(trade.px, trade.sz)}
+
+
+
Size
+
{formatNumber(trade.sz, 4)}
+
+
+
PnL
+ {trade.closedPnl ? (
+
+ {isPnlPositive ? '+' : ''}${formatNumber(trade.closedPnl)}
+
+ ) : (
+
-
+ )}
+
+
+ Fee:
+ ${formatNumber(trade.fee || '0')}
+
+
+
+ );
+ })}
+
+ {!showAllHistory && trades.some(t => t.time <= Date.now() - 30 * 24 * 60 * 60 * 1000) && (
+
+
+
+ )}
+
+ )}
+
+
+
+
+ >
+ );
+}
diff --git a/src/apps/perps/components/TradeSignals.tsx b/src/apps/perps/components/TradeSignals.tsx
new file mode 100644
index 00000000..e01e3149
--- /dev/null
+++ b/src/apps/perps/components/TradeSignals.tsx
@@ -0,0 +1,190 @@
+import { useState, useEffect } from 'react';
+import { Card } from './ui/card';
+import { Button } from './ui/button';
+import { ScrollArea } from './ui/scroll-area';
+import { Badge } from './ui/badge';
+import { Copy, RefreshCw, TrendingUp, TrendingDown } from 'lucide-react';
+import { toast } from 'sonner';
+import { Skeleton } from './ui/skeleton';
+
+interface TradeSignal {
+ symbol: string;
+ side: 'long' | 'short';
+ entry: number | [number, number];
+ stopLoss: number;
+ takeProfits: number[];
+ timestamp?: string;
+}
+
+interface TradeSignalsProps {
+ onCopySignal: (signal: TradeSignal) => void;
+}
+
+export function TradeSignals({ onCopySignal }: TradeSignalsProps) {
+ const [signals, setSignals] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ loadSignals();
+ }, []);
+
+ const loadSignals = async () => {
+ setIsLoading(true);
+ try {
+ // TODO: Replace with your own trade signals API endpoint
+ // const response = await fetch('YOUR_API_ENDPOINT_HERE');
+
+ // For now, return empty signals
+ console.warn('Trade signals API not configured');
+ setSignals([]);
+ } catch (error: any) {
+ console.error('Failed to load signals:', error);
+ toast.error('Failed to load trade signals');
+ setSignals([]);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleCopySignal = (signal: TradeSignal) => {
+ onCopySignal(signal);
+ toast.success('Signal copied to trade form!', {
+ description: `${signal.side.toUpperCase()} ${signal.symbol}`,
+ });
+ };
+
+ const getEntryDisplay = (entry: number | [number, number]) => {
+ if (Array.isArray(entry)) {
+ return `${entry[0]} - ${entry[1]}`;
+ }
+ return entry.toFixed(2);
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Trade Signals
+
+
+
+
+
+ {signals.length === 0 ? (
+
+ No trade signals available
+
+ ) : (
+ signals.map((signal, index) => (
+
+
+
+
+ {signal.side === 'long' ? (
+
+ ) : (
+
+ )}
+
+
+
{signal.symbol}
+
+ {signal.side.toUpperCase()}
+
+
+
+
+
+
+
+
+ Entry:
+
+ {getEntryDisplay(signal.entry)}
+
+
+
+ Stop Loss:
+
+ {signal.stopLoss.toFixed(2)}
+
+
+
+
+ Take Profits:
+
+
+ {signal.takeProfits.map((tp, tpIndex) => (
+
+ {tp.toFixed(2)}
+
+ ))}
+
+
+
+
+ {signal.timestamp && (
+
+ {new Date(signal.timestamp).toLocaleString()}
+
+ )}
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/src/apps/perps/components/TradingChart.tsx b/src/apps/perps/components/TradingChart.tsx
new file mode 100644
index 00000000..ce56bd58
--- /dev/null
+++ b/src/apps/perps/components/TradingChart.tsx
@@ -0,0 +1,306 @@
+import { useEffect, useRef, useState, useCallback } from 'react';
+import {
+ createChart,
+ type IChartApi,
+ type ISeriesApi,
+ type CandlestickData,
+ type Time,
+} from 'lightweight-charts';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '../components/ui/card';
+import { Button } from '../components/ui/button';
+import type { AssetInfo } from '../lib/hyperliquid/types';
+import { PriceTicker } from './PriceTicker';
+
+interface TradingChartProps {
+ selectedAsset: AssetInfo | null;
+}
+
+type Interval = '1m' | '5m' | '15m' | '1h' | '4h' | '1d';
+
+interface CandleResponse {
+ t: number; // timestamp
+ o: string; // open (API returns as string)
+ h: string; // high (API returns as string)
+ l: string; // low (API returns as string)
+ c: string; // close (API returns as string)
+ v: string; // volume (API returns as string)
+}
+
+export function TradingChart({ selectedAsset }: TradingChartProps) {
+ const chartContainerRef = useRef(null);
+ const chartRef = useRef(null);
+ const candlestickSeriesRef = useRef | null>(null);
+ const [interval, setInterval] = useState('1h');
+ const [isLoading, setIsLoading] = useState(false);
+
+ const fetchCandles = useCallback(
+ async (symbol: string, intervalStr: Interval) => {
+ try {
+ setIsLoading(true);
+ const now = Date.now();
+ const intervalMs: Record = {
+ '1m': 60 * 1000,
+ '5m': 5 * 60 * 1000,
+ '15m': 15 * 60 * 1000,
+ '1h': 60 * 60 * 1000,
+ '4h': 4 * 60 * 60 * 1000,
+ '1d': 24 * 60 * 60 * 1000,
+ };
+
+ const startTime = now - 300 * intervalMs[intervalStr]; // Last 300 candles in milliseconds
+
+ const response = await fetch('https://api.hyperliquid.xyz/info', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ type: 'candleSnapshot',
+ req: {
+ coin: symbol,
+ interval: intervalStr,
+ startTime,
+ endTime: now,
+ },
+ }),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('[Chart] API error:', {
+ status: response.status,
+ error: errorText,
+ symbol,
+ intervalStr,
+ });
+ throw new Error(`Candles API error: ${response.status} ${errorText}`);
+ }
+
+ const raw: CandleResponse[] = await response.json();
+
+ if (!Array.isArray(raw)) {
+ console.error('[Chart] Invalid response format:', raw);
+ return [];
+ }
+
+ const candlestickData: CandlestickData