Skip to content

Conversation

@aldin4u
Copy link
Collaborator

@aldin4u aldin4u commented Dec 23, 2025

Description

How Has This Been Tested?

Screenshots (if appropriate):

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Summary by CodeRabbit

  • New Features

    • Full Perps trading UI: asset selector, live charts/ticker, trade form (market/limit, leverage, SL/TP), positions, trade history, signals, copy-trade tiles, sparkline, mobile views, and connect/status UX.
    • Agent wallet & keystore flows: create/import agent accounts, PIN encryption/unlock, reveal/copy/download keys, approve/remove agents, session timeout.
    • Deposit/withdraw bridge flow with Arbitrum integration and transaction UX.
    • New themed UI component library and toaster notifications.
  • Chores

    • Build script memory increase; added runtime dependencies.
  • Tests

    • New script to exercise remote orders API.

✏️ Tip: You can customize this high-level summary in your review settings.

RanaBug and others added 30 commits September 19, 2024 16:42
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In @src/apps/perps/lib/hyperliquid/keystore.ts:
- Around line 328-338: Rename the second parameter in updateBuilderApproval from
approved to builderApproved and change its type to unknown (or any) so the
runtime type-check is meaningful; then coerce it to a boolean before storing
(e.g. check typeof builderApproved !== 'boolean' and warn, then const approved =
Boolean(builderApproved)); keep the storage key getStorageKey(masterAddress,
'builder_approved') and write String(approved) to localStorage.
- Around line 252-261: getImportedAccountAddress can throw when
GLOBAL_ACCOUNT_KEY contains malformed JSON; modify the function to safely handle
corrupt legacy JSON by wrapping the JSON.parse of
localStorage.getItem(GLOBAL_ACCOUNT_KEY) in a try/catch (or use a safe JSON
parse helper), return null on parse error (or fallback to the imported key), and
optionally clear the bad GLOBAL_ACCOUNT_KEY entry; keep the existing preference
order (GLOBAL_IMPORTED_ADDRESS_KEY first, then legacy
GLOBAL_ACCOUNT_KEY.accountAddress) and reference getImportedAccountAddress,
GLOBAL_IMPORTED_ADDRESS_KEY and GLOBAL_ACCOUNT_KEY when making the change.
- Around line 387-397: clearAllAgentWalletsLocal currently accesses localStorage
unguarded causing SSR crashes and it doesn't clear in-memory caches; wrap the
localStorage logic in a browser guard (e.g., check typeof window !== 'undefined'
&& window.localStorage) before iterating/removing keys inside
clearAllAgentWalletsLocal, and also clear any related in-memory caches used by
your module (e.g., agentWalletCache or agentWalletsMap — whatever cache
variable(s) hold agent wallets) so state is consistent between memory and
storage.
- Around line 355-368: getAgentWallet reads localStorage unguarded and will
throw in SSR/test environments; add a runtime guard (e.g., check typeof window
!== 'undefined' && window.localStorage) before any access to localStorage inside
getAgentWallet (including the cachedPrivateKey branch) and return null or
fallback if localStorage is unavailable; update uses of getStorageKey,
localStorage.getItem, and resetInactivityTimer within that guarded block so no
localStorage calls happen when window is undefined.
🧹 Nitpick comments (1)
src/apps/perps/lib/hyperliquid/keystore.ts (1)

403-407: checkWalletMatch can be synchronous.
No awaits; consider dropping async/Promise unless required by an interface.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7d338b7 and d5938d6.

📒 Files selected for processing (1)
  • src/apps/perps/lib/hyperliquid/keystore.ts
🧰 Additional context used
🧬 Code graph analysis (1)
src/apps/perps/lib/hyperliquid/keystore.ts (2)
src/apps/perps/lib/hyperliquid/session-manager.ts (3)
  • initializeSessionManager (19-22)
  • resetInactivityTimer (28-41)
  • clearSession (47-58)
src/apps/perps/lib/encryption.ts (3)
  • encryptWithPin (50-72)
  • EncryptedData (43-47)
  • decryptWithPin (75-97)
🪛 ast-grep (0.40.4)
src/apps/perps/lib/hyperliquid/keystore.ts

[warning] 45-45: Sensitive information detected in localStorage. Storing sensitive data like emails, usernames, or personal information in localStorage exposes it to malicious scripts and XSS attacks. Use secure storage alternatives or avoid storing sensitive data client-side.
Context: localStorage.setItem(getStorageKey(masterAddress, 'address'), address)
Note: [CWE-312] Cleartext Storage of Sensitive Information [REFERENCES]
- https://cwe.mitre.org/data/definitions/312.html
- https://owasp.org/www-community/vulnerabilities/HTML5_Local_Storage_Security

(local-storage-sensitive-information)


[warning] 130-130: Sensitive information detected in localStorage. Storing sensitive data like emails, usernames, or personal information in localStorage exposes it to malicious scripts and XSS attacks. Use secure storage alternatives or avoid storing sensitive data client-side.
Context: localStorage.setItem(getStorageKey(masterAddress, 'address'), address)
Note: [CWE-312] Cleartext Storage of Sensitive Information [REFERENCES]
- https://cwe.mitre.org/data/definitions/312.html
- https://owasp.org/www-community/vulnerabilities/HTML5_Local_Storage_Security

(local-storage-sensitive-information)


[warning] 204-204: Sensitive information detected in localStorage. Storing sensitive data like emails, usernames, or personal information in localStorage exposes it to malicious scripts and XSS attacks. Use secure storage alternatives or avoid storing sensitive data client-side.
Context: localStorage.setItem(GLOBAL_IMPORTED_ADDRESS_KEY, address)
Note: [CWE-312] Cleartext Storage of Sensitive Information [REFERENCES]
- https://cwe.mitre.org/data/definitions/312.html
- https://owasp.org/www-community/vulnerabilities/HTML5_Local_Storage_Security

(local-storage-sensitive-information)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: unit-tests
  • GitHub Check: lint
  • GitHub Check: Cloudflare Pages: x
  • GitHub Check: build
🔇 Additional comments (2)
src/apps/perps/lib/hyperliquid/keystore.ts (2)

64-107: Validate decrypted key format before casting to Hex and prevent unexpected plaintext fallback.

The review identifies two concerns: (1) decryptWithPin returns a string that is cast to Hex without validation, and (2) the plaintext fallback via getAgentWalletLocal ignores the provided PIN entirely.

The proposed fix references using an isHex utility for validation. Viem v2 does provide the isHex(value, { strict?: boolean }) function (strict mode by default validates the full "0x[0-9a-fA-F]*" pattern), which makes the validation approach sound.

However, the caching strategy change from cachedPrivateKey = privateKey as Hex to cachedAgentKeyByMaster.set(norm(masterAddress), privateKey as Hex) requires confirmation that cachedAgentKeyByMaster exists and is the correct pattern in the codebase.


16-23: Type safety for wallet generation is correctly handled by viem.

generatePrivateKey() returns a properly formatted Hex type (0x-prefixed string with 64 hex characters for 32-byte secp256k1 keys), and privateKeyToAccount() correctly expects and processes this type. The return type annotation { address: string; privateKey: Hex } matches the actual output. No additional validation is needed for generated keys since generatePrivateKey() ensures the correct format internally.

If the code later needs to handle decrypted private key strings, viem provides validation utilities (isHex(), isHash() for 32-byte validation) and conversion helpers (hexToBytes(), bytesToHex()) as needed.

Comment on lines +6 to +8
// Module-level cache for unlocked private key (cleared on page refresh or session timeout)
let cachedPrivateKey: Hex | null = null;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: cachedPrivateKey is not keyed by masterAddress (can return the wrong key).
If a user unlocks/stores for master A, then calls getAgentWallet(masterB), the function can return A’s cachedPrivateKey as long as master B has an address stored.

Proposed fix (cache per master)
-// Module-level cache for unlocked private key (cleared on page refresh or session timeout)
-let cachedPrivateKey: Hex | null = null;
+// Cache unlocked keys per master address (cleared on refresh or session timeout)
+const cachedAgentKeyByMaster = new Map<string, Hex>();
+const norm = (addr: string) => addr.toLowerCase();

 initializeSessionManager(() => {
   console.log('[Keystore] Clearing cached private keys due to session timeout');
-  cachedPrivateKey = null;
+  cachedAgentKeyByMaster.clear();
   cachedImportedKey = null; // Also clear imported account cache
 });

 // ...
 export async function storeAgentWalletEncrypted(
   masterAddress: string,
   address: string,
   privateKey: Hex,
   pin: string,
   approved: boolean = false,
   builderApproved: boolean = false
 ): Promise<void> {
   if (typeof window === 'undefined') return;
   try {
     const encrypted = await encryptWithPin(privateKey, pin);
     const storageData = JSON.stringify(encrypted);
     localStorage.setItem(getStorageKey(masterAddress, 'address'), address);
     localStorage.setItem(getStorageKey(masterAddress, 'encrypted_key'), storageData);
     // ...
-    cachedPrivateKey = privateKey;
+    cachedAgentKeyByMaster.set(norm(masterAddress), privateKey);
     resetInactivityTimer();
   } catch (error) {
     // ...
   }
 }

 export async function unlockAgentWallet(
   masterAddress: string,
   pin: string
 ): Promise<{ address: string; privateKey: Hex; approved: boolean; builderApproved: boolean } | null> {
   if (typeof window === 'undefined') return null;
   // ...
   try {
     const encryptedData: EncryptedData = JSON.parse(encryptedkeyData);
     const privateKey = await decryptWithPin(encryptedData, pin);
-    cachedPrivateKey = privateKey as Hex;
+    cachedAgentKeyByMaster.set(norm(masterAddress), privateKey as Hex);
     resetInactivityTimer();
     return { address, privateKey: privateKey as Hex, approved, builderApproved };
   } catch (error) {
     // ...
   }
 }

 export async function getAgentWallet(
   masterAddress: string
 ): Promise<{ address: string; privateKey: Hex; approved: boolean; builderApproved: boolean } | null> {
+  if (typeof window === 'undefined') return null;
   // Check memory cache first
-  if (cachedPrivateKey) {
+  const cached = cachedAgentKeyByMaster.get(norm(masterAddress));
+  if (cached) {
     const address = localStorage.getItem(getStorageKey(masterAddress, 'address'));
     if (address) {
       const approvedItem = localStorage.getItem(getStorageKey(masterAddress, 'approved'));
       const approved = approvedItem === 'true';
       const builderApprovedItem = localStorage.getItem(getStorageKey(masterAddress, 'builder_approved'));
       const builderApproved = builderApprovedItem === 'true';
       resetInactivityTimer();
-      return { address, privateKey: cachedPrivateKey, approved, builderApproved };
+      return { address, privateKey: cached, approved, builderApproved };
     }
   }
   // ...
 }

Also applies to: 32-62, 64-108, 355-381

Comment on lines +328 to +338
export function updateBuilderApproval(
masterAddress: string,
approved: boolean
): void {
if (typeof window === 'undefined') return;
if (typeof approved !== 'boolean') {
console.warn('[Keystore] updateBuilderApproval called with non-boolean:', approved);
approved = !!approved;
}
localStorage.setItem(getStorageKey(masterAddress, 'builder_approved'), String(approved));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

updateBuilderApproval param name/type-check is inconsistent.
The parameter is named approved but represents builderApproved; and since it’s typed boolean, the runtime typeof check is mostly dead code (unless called from untyped JS).

Proposed fix
 export function updateBuilderApproval(
   masterAddress: string,
-  approved: boolean
+  builderApproved: boolean
 ): void {
   if (typeof window === 'undefined') return;
-  if (typeof approved !== 'boolean') {
-    console.warn('[Keystore] updateBuilderApproval called with non-boolean:', approved);
-    approved = !!approved;
-  }
-  localStorage.setItem(getStorageKey(masterAddress, 'builder_approved'), String(approved));
+  localStorage.setItem(getStorageKey(masterAddress, 'builder_approved'), String(builderApproved));
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function updateBuilderApproval(
masterAddress: string,
approved: boolean
): void {
if (typeof window === 'undefined') return;
if (typeof approved !== 'boolean') {
console.warn('[Keystore] updateBuilderApproval called with non-boolean:', approved);
approved = !!approved;
}
localStorage.setItem(getStorageKey(masterAddress, 'builder_approved'), String(approved));
}
export function updateBuilderApproval(
masterAddress: string,
builderApproved: boolean
): void {
if (typeof window === 'undefined') return;
localStorage.setItem(getStorageKey(masterAddress, 'builder_approved'), String(builderApproved));
}
🤖 Prompt for AI Agents
In @src/apps/perps/lib/hyperliquid/keystore.ts around lines 328 - 338, Rename
the second parameter in updateBuilderApproval from approved to builderApproved
and change its type to unknown (or any) so the runtime type-check is meaningful;
then coerce it to a boolean before storing (e.g. check typeof builderApproved
!== 'boolean' and warn, then const approved = Boolean(builderApproved)); keep
the storage key getStorageKey(masterAddress, 'builder_approved') and write
String(approved) to localStorage.

Comment on lines +355 to +368
export async function getAgentWallet(
masterAddress: string
): Promise<{ address: string; privateKey: Hex; approved: boolean; builderApproved: boolean } | null> {
// Check memory cache first
if (cachedPrivateKey) {
const address = localStorage.getItem(getStorageKey(masterAddress, 'address'));
if (address) {
const approvedItem = localStorage.getItem(getStorageKey(masterAddress, 'approved'));
const approved = approvedItem === 'true';
const builderApprovedItem = localStorage.getItem(getStorageKey(masterAddress, 'builder_approved'));
const builderApproved = builderApprovedItem === 'true';
resetInactivityTimer(); // Reset session timeout on wallet access
return { address, privateKey: cachedPrivateKey, approved, builderApproved };
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: getAgentWallet needs a typeof window guard.
It reads localStorage unconditionally, which can crash SSR/test environments.

Proposed fix
 export async function getAgentWallet(
   masterAddress: string
 ): Promise<{ address: string; privateKey: Hex; approved: boolean; builderApproved: boolean } | null> {
+  if (typeof window === 'undefined') return null;
   // ...
 }
🤖 Prompt for AI Agents
In @src/apps/perps/lib/hyperliquid/keystore.ts around lines 355 - 368,
getAgentWallet reads localStorage unguarded and will throw in SSR/test
environments; add a runtime guard (e.g., check typeof window !== 'undefined' &&
window.localStorage) before any access to localStorage inside getAgentWallet
(including the cachedPrivateKey branch) and return null or fallback if
localStorage is unavailable; update uses of getStorageKey, localStorage.getItem,
and resetInactivityTimer within that guarded block so no localStorage calls
happen when window is undefined.

Comment on lines +387 to +397
export function clearAllAgentWalletsLocal(): void {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith('hl_agent_')) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

clearAllAgentWalletsLocal missing window guard (SSR crash) + consider also clearing in-memory caches.

Proposed fix
 export function clearAllAgentWalletsLocal(): void {
+  if (typeof window === 'undefined') return;
   const keysToRemove: string[] = [];
   for (let i = 0; i < localStorage.length; i++) {
     const key = localStorage.key(i);
     if (key?.startsWith('hl_agent_')) {
       keysToRemove.push(key);
     }
   }
   keysToRemove.forEach((key) => localStorage.removeItem(key));
+  // Optionally: clear session/caches if this is meant to be a “full reset”
+  // clearSession();
 }
🤖 Prompt for AI Agents
In @src/apps/perps/lib/hyperliquid/keystore.ts around lines 387 - 397,
clearAllAgentWalletsLocal currently accesses localStorage unguarded causing SSR
crashes and it doesn't clear in-memory caches; wrap the localStorage logic in a
browser guard (e.g., check typeof window !== 'undefined' && window.localStorage)
before iterating/removing keys inside clearAllAgentWalletsLocal, and also clear
any related in-memory caches used by your module (e.g., agentWalletCache or
agentWalletsMap — whatever cache variable(s) hold agent wallets) so state is
consistent between memory and storage.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In @src/apps/perps/components/UnlockWalletModal.tsx:
- Around line 91-93: The modal in UnlockWalletModal is currently non-dismissible
(Dialog uses an empty onOpenChange, DialogContent prevents pointer/escape and
there is no call to onClose), which likely harms UX; either document this
intentional security behavior or restore dismissibility by wiring the Dialog
onOpenChange to call the provided onClose callback, remove or loosen the
onPointerDownOutside/onEscapeKeyDown preventDefault handlers on DialogContent,
and add a visible "Cancel" or "Close" button in the component that invokes
onClose(); update references to onOpenChange, onClose, DialogContent,
onPointerDownOutside, and onEscapeKeyDown accordingly.
🧹 Nitpick comments (2)
src/apps/perps/components/UnlockWalletModal.tsx (2)

49-77: Consolidate duplicate error handling logic.

The else block (lines 58-65) and catch block (lines 66-73) contain identical error handling code. This duplication reduces maintainability.

♻️ Proposed refactor to eliminate duplication
     const handleUnlockAttempt = useCallback(async (pinValue: string) => {
         if (pinValue.length !== 4) return;
 
         setIsLoading(true);
         try {
             const success = await onUnlock(pinValue);
             if (success) {
                 toast.success('Wallet unlocked!');
                 setPin(''); // Clear PIN on success
             } else {
-                // Handle explicit failure (false returned)
-                toast.error('Incorrect PIN');
-                setPin('');
-                setTimeout(() => {
-                    inputRef.current?.focus();
-                }, 100);
+                throw new Error('Incorrect PIN');
             }
         } catch (error) {
             console.error('Unlock failed', error);
             toast.error('Incorrect PIN');
             setPin(''); // Clear PIN on failure to allow retry
             // Refocus input on failure
             setTimeout(() => {
                 inputRef.current?.focus();
             }, 100);
         } finally {
             setIsLoading(false);
         }
     }, [onUnlock]);

107-121: Potential redundancy: both autoFocus prop and manual focus attempts.

The InputOTP has autoFocus={true} (line 113) while the useEffect also manually focuses the input via inputRef.current?.focus() with timeouts (lines 38-45).

This dual approach might be an intentional workaround for mobile reliability (per the commit message "improve mobile PIN input auto-focus"), but could cause redundant focus calls or minor race conditions.

Verify whether:

  1. The autoFocus prop is necessary alongside manual focus
  2. Mobile devices handle autoFocus reliably in dialogs

If manual focus is sufficient, consider removing the autoFocus prop to reduce redundancy.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d5938d6 and 9ab23a9.

📒 Files selected for processing (1)
  • src/apps/perps/components/UnlockWalletModal.tsx
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-12-11T12:40:09.964Z
Learnt from: aldin4u
Repo: pillarwallet/x PR: 478
File: src/apps/pulse/components/App/AppWrapper.tsx:22-23
Timestamp: 2025-12-11T12:40:09.964Z
Learning: In the Pulse app's AppWrapper component (src/apps/pulse/components/App/AppWrapper.tsx), when users enter via `/pulse?searching=true` from PillarX home and select a token or market, they should continue into Pulse to complete their transaction, not navigate back to home. Only when closing the search without making a selection should they return to PillarX home.

Applied to files:

  • src/apps/perps/components/UnlockWalletModal.tsx
📚 Learning: 2025-11-04T14:34:00.373Z
Learnt from: RanaBug
Repo: pillarwallet/x PR: 445
File: src/containers/Authorized.tsx:63-77
Timestamp: 2025-11-04T14:34:00.373Z
Learning: In src/containers/Authorized.tsx, the tempKit useEffect intentionally excludes chainId from its dependency array because the kit is used by useWalletModeVerification to check wallet mode across all supported chains, regardless of the currently selected chainId. The chainId parameter is only used for initial kit configuration and does not affect the multi-chain verification results.

Applied to files:

  • src/apps/perps/components/UnlockWalletModal.tsx
🧬 Code graph analysis (1)
src/apps/perps/components/UnlockWalletModal.tsx (1)
src/apps/perps/components/ui/input-otp.tsx (3)
  • InputOTP (69-69)
  • InputOTPGroup (69-69)
  • InputOTPSlot (69-69)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: unit-tests
  • GitHub Check: build
🔇 Additional comments (5)
src/apps/perps/components/UnlockWalletModal.tsx (5)

1-17: LGTM!

Imports are appropriate and well-organized for the component's requirements.


19-23: LGTM!

The interface is well-typed and the Promise<boolean> return type for onUnlock provides clear success/failure signaling.


26-28: LGTM!

State and ref initialization are appropriate for managing PIN input, loading state, and focus control.


79-82: LGTM!

Form submission handler follows standard patterns correctly.


84-89: LGTM!

Auto-submit logic is correct and the isLoading check properly prevents double submission.

Comment on lines +30 to +47
// Reset PIN when modal opens/closes
useEffect(() => {
if (!isOpen) {
setPin('');
setIsLoading(false);
} else {
// Auto-focus the input when modal opens
// Attempt 1: Fast (for desktop/fast devices)
setTimeout(() => {
inputRef.current?.focus();
}, 50);

// Attempt 2: Slower (for mobile animations to finish)
setTimeout(() => {
inputRef.current?.focus();
}, 500);
}
}, [isOpen]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add cleanup for setTimeout calls to prevent memory leaks and stale references.

The effect creates two timers but doesn't clean them up if isOpen changes or the component unmounts. This can cause focus attempts on unmounted/closed modals and memory leaks.

🐛 Proposed fix with proper cleanup
     // Reset PIN when modal opens/closes
     useEffect(() => {
         if (!isOpen) {
             setPin('');
             setIsLoading(false);
         } else {
             // Auto-focus the input when modal opens
             // Attempt 1: Fast (for desktop/fast devices)
-            setTimeout(() => {
+            const timer1 = setTimeout(() => {
                 inputRef.current?.focus();
             }, 50);
 
             // Attempt 2: Slower (for mobile animations to finish)
-            setTimeout(() => {
+            const timer2 = setTimeout(() => {
                 inputRef.current?.focus();
             }, 500);
+
+            return () => {
+                clearTimeout(timer1);
+                clearTimeout(timer2);
+            };
         }
     }, [isOpen]);

@github-actions github-actions bot requested a deployment to Preview (perps) January 10, 2026 08:32 Abandoned
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Fix all issues with AI agents
In @src/apps/perps/components/TradeForm.tsx:
- Around line 366-418: In onSubmit, several early-return branches (e.g., when
!selectedAsset, !masterAddress, imported missing and agent.builderApproved is
false, and when privateKey is not found) call toast.error or return without
clearing the initial toast created by toast.loading; update all early-return
error paths within onSubmit (including the branch checking agent.builderApproved
and the final privateKey check) to dismiss or replace the loading toast by
passing { id: toastId } to toast.error (or call toast.dismiss(toastId) /
toast.remove(toastId)) before returning, and ensure setIsSubmitting(false) is
invoked where loading state must be cleared; look for functions/vars referenced
here (onSubmit, toastId, getAgentWallet, getImportedAccount, setIsSubmitting,
masterAddress, selectedAsset) and apply the change to every exit path so the
"Placing order..." toast is always cleared.
- Around line 121-184: The current handlers mutate takeProfits via setValue
which desyncs useFieldArray's tpFields/IDs; instead use useFieldArray helpers:
in handleAddTp call appendTp for the new TP (with its price/distance/ratio
placeholder) then compute redistributed ratios and call update for each index
(use update(index, { ...tp, ratio: newRatio })) to preserve field IDs and RHF
metadata; in handleRemoveTp call removeTp(index) to remove the entry, then
recompute ratios for the remaining entries and call update for each remaining
index (or call replace(updatedArray) if you want to replace all while preserving
array IDs via replace) rather than calling setValue('takeProfits', ...).
- Around line 246-282: The prefill logic in the TradeForm useEffect creates TP
ratios with Math.floor(100 / tps.length) which can sum to less than 100 and fail
validation; replace that manual calculation by calling the existing
getDistributedRatios(count) to get an array of ratios that sums to 100, and then
setValue('takeProfits', tps.map((p, i) => ({ price: p, ratio: ratios[i],
distance: calculateDistance(p, ep) }))); ensure you parse and filter takeProfits
into tps as before and obtain ratios = getDistributedRatios(tps.length) before
mapping.
- Around line 500-567: The stop-loss is posted at full size before take-profits,
so if partial TPs fill first the SL may be clamped or rejected; update the flow
in TradeForm.tsx to either (a) post SL after submitting TPs and adjust its size
to the remaining position, or (b) keep posting SL first but handle
ReduceOnly/clamp responses from placeTriggerOrderAgent by detecting the
error/response, calculating the new remaining size from executed TPs, and
retrying the SL with the adjusted size (or cancelling if zero). Locate the SL
placement block and the take-profit loop (references: placeTriggerOrderAgent,
data.stopLoss, data.takeProfits, and the tps loop) and implement one of the two
strategies plus error handling for ReduceOnly responses to ensure SL uses the
correct reduce-only size.

In @src/apps/perps/lib/hyperliquid/sdk.ts:
- Around line 17-57: The price string generation in placeMarketOrderAgent (the
marketPrice variable) uses parseFloat(...toPrecision(5)).toString(), which can
produce scientific notation; replace this with a decimal-notation formatter
helper (e.g., formatPriceDecimal(value, sig=5)) that returns a plain decimal
string (no "e" notation) while honoring 5 significant figures and trimming
trailing zeros; call that helper wherever prices are formatted (marketPrice here
and the other spots referenced) before putting p into orderRequest so
Hyperliquid receives a proper decimal string.

In @src/apps/perps/lib/hyperliquid/signing.ts:
- Around line 29-92: signUserAction currently builds an unused actionPayload and
signs a static phantom agent; replace the dead-code path by msgpack-serializing
the actual actionPayload (action, nonce, vaultAddress), keccak256-hashing it (or
call createL1ActionHash if available) to obtain the connectionId, set
phantomAgent.connectionId to that hash, then call walletClient.signTypedData
with that phantomAgent so the signature binds to the action+nonce; remove the
unused actionHash and actionPayload variables and ensure you still parse r/s/v
from the returned signature as before.
- Around line 234-296: The signature parsing extracts v directly from signature
bytes but doesn't normalize it, which can yield 0/1 or 27/28 depending on
signer; update signApproveAgentAction, signApproveBuilderFeeAction,
signAgentAction, and signUserAction to normalize v after parsing (if parsed v <
2 then add 27 to produce 27/28) before returning or sending to the API so all
code and API submissions use a consistent v format.
🧹 Nitpick comments (4)
src/apps/perps/components/TradeForm.tsx (2)

40-69: tradeSchema has a no-op .refine and misses key invariants (entryPrice, TP ratios).
Right now validation effectively doesn’t enforce anything beyond field-level constraints; also entryPrice isn’t required for limit orders, and TP ratio sum isn’t enforced until submit-time.

Proposed direction (keep schema meaningful)
  • Remove the placeholder .refine.
  • Add an orderType field ('market' | 'limit') to schema + keep it in sync with isMarketOrder, so you can require entryPrice iff orderType === 'limit'.
  • Add a schema-level check for TP ratio sum (and optionally >0 when TP exists) so errors surface inline before submit.

1127-1138: Remove render-time debug logging before merging to staging.
The IIFE console.log in render and click handler logs will spam prod consoles and can leak sensitive trading context during normal use.

Proposed fix
-        {/* Debug logging */}
-        {(() => {
-          console.log('[TradeForm] Button state:', {
-            isSubmitting,
-            'errors.amountUSD': errors.amountUSD,
-            isBelowMinimum,
-            minUSD,
-            amountUSD,
-            disabled: isSubmitting || !!errors.amountUSD || isBelowMinimum,
-          });
-          return null;
-        })()}
-
         <Button
           type="submit"
           disabled={isSubmitting || !!errors.amountUSD || isBelowMinimum}
-          onClick={(e) => {
-            console.log('[TradeForm] Submit button clicked event', {
-              disabled: isSubmitting || !!errors.amountUSD || isBelowMinimum,
-              defaultPrevented: e.defaultPrevented,
-            });
-          }}
           className={`w-full ${errors.amountUSD || isBelowMinimum

Also applies to: 1143-1148

src/apps/perps/lib/hyperliquid/signing.ts (1)

366-380: Duplicate generateAgentWallet vs src/apps/perps/lib/hyperliquid/keystore.ts.
This is easy to drift (and it already differs). Prefer a single implementation (likely keystore) and re-export it where needed.

src/apps/perps/lib/hyperliquid/sdk.ts (1)

8-12: Use privateKeyToAccount consistently in approveAgentSDK and approveBuilderFeeSDK for code consistency.

While both approveAgentSDK (line 183) and approveBuilderFeeSDK (line 201) pass a raw Hex string as the wallet parameter to ExchangeClient, and getExchangeClientForAgent (line 11) converts the key via privateKeyToAccount, the @nktkas/hyperliquid library accepts both formats. Standardize to use privateKeyToAccount for consistency across all three functions:

 export async function approveAgentSDK(
   masterPrivateKey: Hex,
   agentAddress: string,
   agentName?: string
 ): Promise<any> {
+  const account = privateKeyToAccount(masterPrivateKey);
   const transport = new HttpTransport();
-  const client = new ExchangeClient({ wallet: masterPrivateKey, transport });
+  const client = new ExchangeClient({ wallet: account, transport });
   ...
 }

 export async function approveBuilderFeeSDK(
   masterPrivateKey: Hex,
   builderAddress: string,
   maxFeeRate: string
 ): Promise<any> {
+  const account = privateKeyToAccount(masterPrivateKey);
   const transport = new HttpTransport();
-  const client = new ExchangeClient({ wallet: masterPrivateKey, transport });
+  const client = new ExchangeClient({ wallet: account, transport });
   ...
 }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9ab23a9 and e448eea.

📒 Files selected for processing (4)
  • src/apps/perps/components/TradeForm.tsx
  • src/apps/perps/lib/hyperliquid/client.ts
  • src/apps/perps/lib/hyperliquid/sdk.ts
  • src/apps/perps/lib/hyperliquid/signing.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/apps/perps/lib/hyperliquid/client.ts
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-11-21T13:10:33.422Z
Learnt from: aldin4u
Repo: pillarwallet/x PR: 461
File: src/apps/pulse/components/Search/MarketList.tsx:10-15
Timestamp: 2025-11-21T13:10:33.422Z
Learning: In the Pulse app's MarketList component (src/apps/pulse/components/Search/MarketList.tsx), markets should display liquidity (not price) in the right-hand column. This is per the design specification.

Applied to files:

  • src/apps/perps/components/TradeForm.tsx
🧬 Code graph analysis (1)
src/apps/perps/lib/hyperliquid/signing.ts (2)
src/apps/perps/lib/hyperliquid/types.ts (1)
  • HyperliquidAction (57-60)
src/apps/perps/lib/hyperliquid/keystore.ts (1)
  • generateAgentWallet (16-23)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: unit-tests
  • GitHub Check: lint
  • GitHub Check: build
  • GitHub Check: Cloudflare Pages: x

Comment on lines +121 to +184
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', []);
}
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

TP add/remove should use useFieldArray helpers (replace/update) to avoid stale fields/IDs.
setValue('takeProfits', ...) can desync tpFields/field IDs and tends to lose the field-array ergonomics (dirty/touched/validation behavior).

Proposed fix
 const {
   fields: tpFields,
   append: appendTp,
   remove: removeTp,
+  replace: replaceTp,
 } = useFieldArray({
   control,
   name: 'takeProfits',
 });

 // Handle adding TP with auto-redistribution
 const handleAddTp = () => {
   const newCount = tpFields.length + 1;
   const ratios = getDistributedRatios(newCount);
   const currentValues = getValues('takeProfits') || [];
   const updatedValues = currentValues.map((tp, i) => ({
     ...tp,
     ratio: ratios[i],
   }));

   updatedValues.push({
     price: 0,
     ratio: ratios[newCount - 1],
     distance: 0,
   });

-  setValue('takeProfits', updatedValues);
+  replaceTp(updatedValues);
 };

 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);
+    replaceTp(updatedValues);
   } else {
-    setValue('takeProfits', []);
+    replaceTp([]);
   }
 };
🤖 Prompt for AI Agents
In @src/apps/perps/components/TradeForm.tsx around lines 121 - 184, The current
handlers mutate takeProfits via setValue which desyncs useFieldArray's
tpFields/IDs; instead use useFieldArray helpers: in handleAddTp call appendTp
for the new TP (with its price/distance/ratio placeholder) then compute
redistributed ratios and call update for each index (use update(index, { ...tp,
ratio: newRatio })) to preserve field IDs and RHF metadata; in handleRemoveTp
call removeTp(index) to remove the entry, then recompute ratios for the
remaining entries and call update for each remaining index (or call
replace(updatedArray) if you want to replace all while preserving array IDs via
replace) rather than calling setValue('takeProfits', ...).

Comment on lines +246 to +282
// 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]);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prefill TP ratios can sum to < 100%, causing immediate submit failure.
prefilledData.takeProfits uses Math.floor(100 / tps.length) for every TP and drops the remainder, so totalRatio !== 100 and the submit-time check rejects.

Proposed fix (reuse getDistributedRatios)
 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;
+  const ratios = getDistributedRatios(tps.length);

   setValue(
     'takeProfits',
-    tps.map((p) => ({
+    tps.map((p, i) => ({
       price: p,
-      ratio: ratio,
+      ratio: ratios[i] ?? 0,
       distance: calculateDistance(p, ep),
     }))
   );
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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]);
// 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 ratios = getDistributedRatios(tps.length);
setValue(
'takeProfits',
tps.map((p, i) => ({
price: p,
ratio: ratios[i] ?? 0,
distance: calculateDistance(p, ep),
}))
);
}
}
}, [prefilledData, setValue, entryPrice, side]);
🤖 Prompt for AI Agents
In @src/apps/perps/components/TradeForm.tsx around lines 246 - 282, The prefill
logic in the TradeForm useEffect creates TP ratios with Math.floor(100 /
tps.length) which can sum to less than 100 and fail validation; replace that
manual calculation by calling the existing getDistributedRatios(count) to get an
array of ratios that sums to 100, and then setValue('takeProfits', tps.map((p,
i) => ({ price: p, ratio: ratios[i], distance: calculateDistance(p, ep) })));
ensure you parse and filter takeProfits into tps as before and obtain ratios =
getDistributedRatios(tps.length) before mapping.

Comment on lines 500 to 567
// 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: { b: BUILDER_ADDRESS, f: BUILDER_FEE_ORDER },
});
}

// 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: { b: BUILDER_ADDRESS, f: BUILDER_FEE_ORDER },
});
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Hyperliquid reduce-only trigger order size exceeds position clamp or reject

💡 Result:

Short answer: Hyperliquid enforces reduce-only semantics on trigger (TP/SL) orders — if your trigger order size would increase or flip the position it will be limited or canceled rather than opening a larger/ opposite position. The platform either (a) clamps the executed amount to the portion that actually reduces your current position, or (b) rejects/cancels the reduce-only order with a reduce-only error/status (e.g., "ReduceOnly" / "reduceOnlyCanceled") when it would increase/flip the position. [1][2][3]

What to expect and how to handle it

  • If you place a reduce-only trigger whose size > current position, fills will be limited to the quantity needed to reduce/close the position. If no position exists (or the order would flip sides) the order is canceled/rejected. [1]
  • The API returns explicit statuses/errors for these cases (ReduceOnly error at placement or reduceOnlyCanceled in order status). Use the info/order-status endpoint to inspect the final status. [2][3]
  • Practical fixes: compute and submit trigger size <= current position (query position via API before placing the trigger), or handle the ReduceOnly/rejected response in your bot to adjust/cancel the trigger automatically.

Sources: Hyperliquid order-types (reduce-only behavior), API error responses, and info endpoint docs. [1][2][3]


🌐 Web query:

Hyperliquid API documentation trigger orders reduce-only position size behavior

💡 Result:

Short answer: Trigger orders with reduce-only set will not increase or flip your position. Hyperliquid enforces reduce-only at placement/execution: trigger orders use mark price and can be market or limit, and the API accepts a reduceOnly flag; if executing a trigger would increase/flip your position it will be prevented (you’ll get a ReduceOnly error), and fills will be limited to the portion that actually reduces/closes the existing position. [1][2][3][4]

Sources

  • Exchange API (place order fields, reduceOnly). [1]
  • Order types — Reduce Only description. [2]
  • Error list (ReduceOnly error string). [3]
  • Trigger/TP/SL docs (trigger orders use mark price; stop-market example with reduce_only flag). [4]

SL may be rejected or clamped if partial TPs execute first.
Hyperliquid clamps reduce-only orders to the remaining position size or rejects them with a ReduceOnly error—it will not over-close. However, the SL is posted at full size regardless of TP execution. If some TPs fill first, the SL may be unexpectedly clamped or rejected rather than execute at full size. Consider tracking which TPs execute and adjusting or canceling the SL accordingly, or handle ReduceOnly rejection responses to retry with adjusted size.

🤖 Prompt for AI Agents
In @src/apps/perps/components/TradeForm.tsx around lines 500 - 567, The
stop-loss is posted at full size before take-profits, so if partial TPs fill
first the SL may be clamped or rejected; update the flow in TradeForm.tsx to
either (a) post SL after submitting TPs and adjust its size to the remaining
position, or (b) keep posting SL first but handle ReduceOnly/clamp responses
from placeTriggerOrderAgent by detecting the error/response, calculating the new
remaining size from executed TPs, and retrying the SL with the adjusted size (or
cancelling if zero). Locate the SL placement block and the take-profit loop
(references: placeTriggerOrderAgent, data.stopLoss, data.takeProfits, and the
tps loop) and implement one of the two strategies plus error handling for
ReduceOnly responses to ensure SL uses the correct reduce-only size.

Comment on lines +17 to +57
export async function placeMarketOrderAgent(
privateKey: Hex,
params: {
coinId: number;
isBuy: boolean;
size: number;
currentPrice: number;
reduceOnly?: boolean;
builder?: { b: string; f: number };
}
): Promise<any> {
const client = getExchangeClientForAgent(privateKey);

// Use 5% slippage to stay within Hyperliquid's 95% rule
// For closing positions (reduce-only), checking price might be less critical if we just want out,
// but we still need a limit price for the IOc order.
const marketPrice = params.isBuy
? parseFloat((params.currentPrice * 1.05).toPrecision(5)).toString()
: parseFloat((params.currentPrice * 0.95).toPrecision(5)).toString();

const orderRequest = {
orders: [
{
a: params.coinId,
b: params.isBuy,
p: marketPrice,
s: params.size.toString(),
r: params.reduceOnly ?? false,
t: { limit: { tif: 'Ioc' as const } },
},
],
grouping: 'na' as const,
builder: params.builder,
};

console.log('[SDK] Placing market order:', orderRequest);
const response = await client.order(orderRequest);
console.log('[SDK] Order response:', response);

return response;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the file structure and the full functions mentioned
git ls-files | grep -E "hyperliquid|perps" | head -20

Repository: pillarwallet/x

Length of output: 907


🏁 Script executed:

# Check the file size to determine how to read it
wc -l src/apps/perps/lib/hyperliquid/sdk.ts

Repository: pillarwallet/x

Length of output: 99


🏁 Script executed:

# Read the relevant sections of the file
cat -n src/apps/perps/lib/hyperliquid/sdk.ts

Repository: pillarwallet/x

Length of output: 7885


🏁 Script executed:

# Test the toPrecision behavior with various price values
python3 << 'EOF'
# Simulate JavaScript's toPrecision and toString behavior

# Test cases covering typical crypto prices and edge cases
test_prices = [
    100,           # Normal price
    0.01,          # 2 decimal places
    0.001,         # 3 decimal places
    0.0001,        # 4 decimal places
    0.00001,       # 5 decimal places
    0.000001,      # 6 decimal places (very small)
    1.05,          # With decimal multiplier
    0.95 * 100,    # Regular calculation
]

for price in test_prices:
    # In JavaScript, toPrecision returns a string
    # Let's check what Python's equivalent would be
    # Python's f-string with .5g gives similar behavior
    precision_result = f"{price:.5g}"
    
    # Simulate parseFloat (which JavaScript uses)
    back_to_num = float(precision_result)
    
    # Then toString()
    final_string = str(back_to_num)
    
    print(f"Price: {price:15} -> toPrecision(5): {precision_result:15} -> toString(): {final_string}")
EOF

Repository: pillarwallet/x

Length of output: 144


🏁 Script executed:

# Check Hyperliquid SDK type definitions or examples to understand expected price format
fd -e ts -e js . --search-path src/apps/perps | xargs grep -l "hyperliquid\|ExchangeClient" | head -5

Repository: pillarwallet/x

Length of output: 217


🏁 Script executed:

# Look for any existing tests or examples that show order placement
fd -e test.ts -e spec.ts -e "test.js" . | head -10

Repository: pillarwallet/x

Length of output: 460


🌐 Web query:

Hyperliquid API order placement endpoint price parameter format specification

💡 Result:

Summary — price parameter format for Hyperliquid order placement

  • Field type: price is sent as a string in the order action (e.g. "p": "43250.0" or in unified API "price": ""). [1][2][3]
  • No trailing zeros: trailing zeros and unnecessary decimal points must be removed before signing/sending (e.g. 12345.0 → "12345", 0.123450 → "0.12345"). SDKs usually normalize this automatically. [4][1]
  • Precision rules: prices must follow the exchange tick/significant‑figure rules (Hyperliquid enforces tick size / max significant figures — typically up to 5 significant figures; perp/spot max decimal places may differ). Confirm exact tick/precision for the symbol via market metadata before formatting. [4][2]
  • Signing note: use the exact string format used in the signed action (so formatting/trailing‑zero removal matters for signature). [1][6]

Examples

  • Valid: "p": "65000" or "price": "65000"
  • Valid small: "p": "0.00123"
  • Invalid (trailing zero): "p": "12345.0" (should be "12345"). [3][4]

Sources: Hyperliquid exchange/placeOrder docs and SDK notes. [1][2][3][4]

References
[1] Hyperliquid Exchange endpoint (order fields and signing).
[2] Hyperliquid unified placeOrder documentation (price as string).
[3] Hypereth / Hyperliquid API examples (placeOrder).
[4] SDK / package notes on trailing‑zero handling and precision.


🌐 Web query:

@nktkas/hyperliquid SDK order price string format requirements

💡 Result:

Summary — price string rules for @nktkas/hyperliquid SDK:

  • Allowed types: string or number. [1]
  • No trailing zeros in the string (e.g. "12345.0" → "12345", "0.123450" → "0.12345"). The SDK auto-normalizes this for high-level methods. [2][3]
  • Significant-figure limit: up to 5 significant figures (integer prices are always allowed). [1]
  • Maximum decimal places:
    • Perpetuals (perp): max (6 - szDecimals) decimal places (default perp behavior described as max 6 decimals overall). [1]
    • Spot: max (8 - szDecimals) decimal places (docs note spot allows more decimals). [1]
      (Get szDecimals from SymbolConverter.getSzDecimals() or market meta.) [1]

If you need implementation examples or a formatter call, tell me which language/runtime and I’ll paste the snippet.


Use a decimal-notation formatter instead of toPrecision(5) to avoid scientific notation in price strings.

The pattern parseFloat(number.toPrecision(5)).toString() can produce scientific notation (e.g., "1e-7" for small prices). Hyperliquid's API expects prices as decimal strings (e.g., "0.00001" or "65000"), and scientific notation may cause order placement to fail. While the exchange enforces 5 significant figures, proper formatting should use a decimal formatter to ensure compliant price strings.

Applies to lines 34–35, 80, and 121–129.

🤖 Prompt for AI Agents
In @src/apps/perps/lib/hyperliquid/sdk.ts around lines 17 - 57, The price string
generation in placeMarketOrderAgent (the marketPrice variable) uses
parseFloat(...toPrecision(5)).toString(), which can produce scientific notation;
replace this with a decimal-notation formatter helper (e.g.,
formatPriceDecimal(value, sig=5)) that returns a plain decimal string (no "e"
notation) while honoring 5 significant figures and trimming trailing zeros; call
that helper wherever prices are formatted (marketPrice here and the other spots
referenced) before putting p into orderRequest so Hyperliquid receives a proper
decimal string.

Comment on lines +29 to +92
export async function signUserAction(
walletClient: WalletClient,
action: HyperliquidAction,
nonce: number
): Promise<{ r: string; s: string; v: number }> {
if (!walletClient.account) {
throw new Error('No account connected');
}

// Construct the action payload
const actionPayload = {
action,
nonce,
vaultAddress: null,
};

// Convert to phantom agent format for signing
const phantomAgent = {
source: 'a',
connectionId: hashTypedData({
domain: HL_DOMAIN,
types: {
Agent: [
{ name: 'source', type: 'string' },
{ name: 'connectionId', type: 'bytes32' },
],
},
primaryType: 'Agent',
message: {
source: 'a',
connectionId:
'0x0000000000000000000000000000000000000000000000000000000000000000' as Hex,
},
}),
};

// Sign the action
const actionHash = hashTypedData({
domain: HL_DOMAIN,
types: {
HyperliquidTransaction: [
{ name: 'source', type: 'string' },
{ name: 'connectionId', type: 'bytes32' },
],
},
primaryType: 'HyperliquidTransaction',
message: phantomAgent,
});

const signature = await walletClient.signTypedData({
account: walletClient.account,
domain: HL_DOMAIN,
types: HL_TYPES,
primaryType: 'HyperliquidTransaction',
message: phantomAgent,
});

// Parse signature
const r = signature.slice(0, 66);
const s = '0x' + signature.slice(66, 130);
const v = parseInt(signature.slice(130, 132), 16);

return { r, s, v };
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n src/apps/perps/lib/hyperliquid/signing.ts | head -40

Repository: pillarwallet/x

Length of output: 1320


🏁 Script executed:

# Search for keystore.ts mentioned in the comment
fd -t f keystore.ts

Repository: pillarwallet/x

Length of output: 100


🏁 Script executed:

# Look for HL_DOMAIN and HL_TYPES definitions
rg -n "HL_DOMAIN|HL_TYPES" src/apps/perps/lib/hyperliquid/ -A 2 -B 2

Repository: pillarwallet/x

Length of output: 2821


🏁 Script executed:

# Check if actionPayload or actionHash are used anywhere else in the file or related files
rg -n "actionPayload|actionHash" src/apps/perps/lib/hyperliquid/

Repository: pillarwallet/x

Length of output: 210


🏁 Script executed:

# Look for test files or examples of Hyperliquid signing
fd -t f "(test|spec|example)" src/apps/perps/lib/hyperliquid/

Repository: pillarwallet/x

Length of output: 40


🏁 Script executed:

cat -n src/apps/perps/lib/hyperliquid/keystore.ts | head -100

Repository: pillarwallet/x

Length of output: 4797


🏁 Script executed:

# Look for any comments or documentation about Hyperliquid signing protocol
rg -n "phantom|agent|replay" src/apps/perps/lib/hyperliquid/ -i

Repository: pillarwallet/x

Length of output: 7872


🏁 Script executed:

# Check if there are any integration tests or usage examples
fd -t f "." src/apps/perps | grep -E "(test|spec|__)" | head -20

Repository: pillarwallet/x

Length of output: 103


🏁 Script executed:

# Look at the full keystore.ts to compare signing approaches
wc -l src/apps/perps/lib/hyperliquid/keystore.ts

Repository: pillarwallet/x

Length of output: 104


🏁 Script executed:

# See the full context of signUserAction including what happens after
cat -n src/apps/perps/lib/hyperliquid/signing.ts | sed -n '29,100p'

Repository: pillarwallet/x

Length of output: 2258


🏁 Script executed:

# Look at signAgentAction for comparison
cat -n src/apps/perps/lib/hyperliquid/signing.ts | sed -n '298,330p'

Repository: pillarwallet/x

Length of output: 1103


🏁 Script executed:

# Check if there are any comments or documentation files about Hyperliquid protocol
fd -t f "README|SPEC|protocol|hyperliquid" src/apps/perps/ -i

Repository: pillarwallet/x

Length of output: 142


🏁 Script executed:

# Search for how Hyperliquid SDK itself handles signing
rg -n "phantomAgent|connectionId" src/apps/perps/lib/hyperliquid/ -B 3 -A 3

Repository: pillarwallet/x

Length of output: 6146


🌐 Web query:

Hyperliquid EIP-712 signing protocol phantom agent wallet

💡 Result:

Short summary:

  • Hyperliquid uses two EIP‑712 signing flows:

    • L1 actions (trading) use a "phantom agent" construction: action is msgpack‑serialized, nonce/vault appended, keccak256 hashed to produce a connectionId (the phantom agent), then signed as EIP‑712 typed data. Chain ID for L1/agent signing is 1337 and EIP‑712 domain = "Exchange". Agent (bot) keypairs — called agent wallets — are authorized to sign these L1 actions. [1][2][3]

    • User‑signed actions (admin, approvals, transfers, approving agent wallets) use direct EIP‑712 typed data (domain "HyperliquidSignTransaction", Chain ID 0x66eee / 421614). Approving an agent wallet is done via a user‑signed action. [1][2]

  • Phantom (the wallet) exposes EIP‑712 signing APIs (signTypedData / ethereum signing) in its SDK/browser connect, so a web app can call Phantom to produce the required EIP‑712 signatures (either for direct user‑signed actions or for signing the agent/phantom agent typed data when the agent’s private key is held in an appropriate signer). Confirm you call the correct signTypedData path for Ethereum messages. [4][5]

Notes for implementation:

  • For L1 actions you must serialize with msgpack and follow Hyperliquid’s exact type/schema so the SDK or your implementation produces the same connectionId hash before building the EIP‑712 typed data. The Hyperliquid SDKs provide helpers (e.g., signL1Action / createL1ActionHash) to avoid manual errors. [2][3][4]
  • For approving or registering agent wallets, use the user‑signed EIP‑712 flow (domain "HyperliquidSignTransaction"). [1][2]
  • Test on Hyperliquid testnet endpoints and verify signatures locally first. [1]

Sources:
[1] Hyperliquid signing overview / Chainstack documentation.
[2] Hyperliquid L1 action signing (msgpack → phantom agent) / Chainstack documentation.
[3] Hyperliquid user‑signed actions / Chainstack documentation.
[4] Hyperliquid signing utilities (createL1ActionHash, signL1Action) / Hyperliquid docs.
[5] Phantom developer docs — signTypedData / Ethereum signing in Phantom SDK.

If you want, I can: (a) show a minimal code example using Phantom's signTypedData to sign a Hyperliquid typed payload, or (b) walk through approving an agent wallet step‑by‑step. Which would you like?


🌐 Web query:

Hyperliquid user action signing connectionId

💡 Result:

ConnectionId (in Hyperliquid L1-action signing) is the keccak256 hash of the serialized L1 action (often produced by the SDK function createL1ActionHash). That hash is used as the "phantom agent" identifier and is included in the EIP‑712 typed-data payload (the connectionId field) when signing L1 actions (trading ops). To produce it you generally:

  • MsgPack‑serialize the L1 action object with the exact field ordering used by the SDK.
  • Include any required fields (nonce / timestamp, vault address when applicable) exactly as the SDK expects.
  • Take keccak256 of the resulting bytes — that result is the connectionId used in the EIP‑712 structure and signing flow.

References / implementation notes: SDKs expose createL1ActionHash / signL1Action helpers and recommend using them (they handle msgpack order, chain id, domain, etc.). See Hyperliquid signing docs and the community SDK docs for examples. [1][2]

Sources:
[1] Hyperliquid — Signing (developer docs / SDK guidance). [2] Hyperliquid signing utilities (createL1ActionHash explanation) in community SDK.


Remove dead code and bind signature to the actual action and nonce via msgpack serialization.

signUserAction constructs actionPayload but never uses it. The signature is computed over a static "phantom agent" with hardcoded zero values, not the actual action and nonce parameters. Per the Hyperliquid protocol, L1 actions must be msgpack-serialized (including nonce and vault address), then keccak256-hashed to derive the connectionId, before signing. The current implementation skips serialization entirely, meaning:

  1. The signature does not prove which action was authorized
  2. The nonce is not bound to the signature (potential replay vulnerability)
  3. actionPayload and actionHash variables are dead code

Use Hyperliquid SDK helpers (e.g., createL1ActionHash) or properly serialize the action with msgpack, hash it, and include that hash as the connectionId in the phantom agent before signing.

🤖 Prompt for AI Agents
In @src/apps/perps/lib/hyperliquid/signing.ts around lines 29 - 92,
signUserAction currently builds an unused actionPayload and signs a static
phantom agent; replace the dead-code path by msgpack-serializing the actual
actionPayload (action, nonce, vaultAddress), keccak256-hashing it (or call
createL1ActionHash if available) to obtain the connectionId, set
phantomAgent.connectionId to that hash, then call walletClient.signTypedData
with that phantomAgent so the signature binds to the action+nonce; remove the
unused actionHash and actionPayload variables and ensure you still parse r/s/v
from the returned signature as before.

Comment on lines +45 to +76
// Convert to phantom agent format for signing
const phantomAgent = {
source: 'a',
connectionId: hashTypedData({
domain: HL_DOMAIN,
types: {
Agent: [
{ name: 'source', type: 'string' },
{ name: 'connectionId', type: 'bytes32' },
],
},
primaryType: 'Agent',
message: {
source: 'a',
connectionId:
'0x0000000000000000000000000000000000000000000000000000000000000000' as Hex,
},
}),
};

// Sign the action
const actionHash = hashTypedData({
domain: HL_DOMAIN,
types: {
HyperliquidTransaction: [
{ name: 'source', type: 'string' },
{ name: 'connectionId', type: 'bytes32' },
],
},
primaryType: 'HyperliquidTransaction',
message: phantomAgent,
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Inconsistent “phantom agent” derivation between user vs agent signing paths.
signUserAction uses hashTypedData(...) to derive connectionId, while signAgentAction uses keccak256(toHex(JSON.stringify(...))). If these are meant to represent the same message, they should be generated identically.

Also applies to: 298-330

Comment on lines +234 to +296
export async function signApproveAgentAction(
walletClient: WalletClient,
action: any
): Promise<{ r: string; s: string; v: number }> {
if (!walletClient.account) {
throw new Error('No account connected');
}

const { domain, types, primaryType, message } = getApproveAgentTypedData(
action.hyperliquidChain,
action.signatureChainId,
action.agentAddress,
action.agentName,
action.nonce
);

const signature = await walletClient.signTypedData({
account: walletClient.account,
domain,
types,
primaryType,
message,
});

// Parse signature
const r = signature.slice(0, 66);
const s = '0x' + signature.slice(66, 130);
const v = parseInt(signature.slice(130, 132), 16);

return { r, s, v };
}

export async function signApproveBuilderFeeAction(
walletClient: WalletClient,
action: any
): Promise<{ r: string; s: string; v: number }> {
if (!walletClient.account) {
throw new Error('No account connected');
}

const { domain, types, primaryType, message } = getApproveBuilderFeeTypedData(
action.hyperliquidChain,
action.signatureChainId,
action.maxFeeRate,
action.builder,
action.nonce
);

const signature = await walletClient.signTypedData({
account: walletClient.account,
domain,
types,
primaryType,
message,
});

// Parse signature
const r = signature.slice(0, 66);
const s = '0x' + signature.slice(66, 130);
const v = parseInt(signature.slice(130, 132), 16);

return { r, s, v };
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the signing.ts file to see the full context
wc -l src/apps/perps/lib/hyperliquid/signing.ts

Repository: pillarwallet/x

Length of output: 103


🏁 Script executed:

# Read the relevant sections and more context around them
cat -n src/apps/perps/lib/hyperliquid/signing.ts | sed -n '220,370p'

Repository: pillarwallet/x

Length of output: 4781


🏁 Script executed:

# Search for how the returned r, s, v values are used in the codebase
rg -B 3 -A 3 'signApproveAgentAction|signApproveBuilderFeeAction' --type ts

Repository: pillarwallet/x

Length of output: 1980


🏁 Script executed:

# Search for where r, s, v from signApproveAgentAction/signApproveBuilderFeeAction are used
rg -B 5 -A 5 'signApproveAgentAction|signApproveBuilderFeeAction' src/apps/perps/components/ --type ts --type tsx

Repository: pillarwallet/x

Length of output: 85


🏁 Script executed:

# Look for API calls that use the signature data
rg 'postExchange|postBuilder' -A 10 -B 5 src/apps/perps/ --type ts --type tsx

Repository: pillarwallet/x

Length of output: 85


🏁 Script executed:

# Check package.json for viem version
cat package.json | grep -A 5 '"viem"'

Repository: pillarwallet/x

Length of output: 281


🏁 Script executed:

# Search for any signature reconstruction or verification patterns
rg 'signature.*toString.*16|padStart.*0|slice.*130' src/apps/perps/lib/hyperliquid/

Repository: pillarwallet/x

Length of output: 1020


🏁 Script executed:

# Search for API client code that uses the signature
rg 'postExchange|postBuilder|submitSignature' src/apps/perps/lib/hyperliquid/ -A 10

Repository: pillarwallet/x

Length of output: 915


🏁 Script executed:

# Find the builder client and see how it uses the signature
find src/apps/perps/lib/hyperliquid -type f -name '*.ts' | head -20

Repository: pillarwallet/x

Length of output: 553


🏁 Script executed:

# Look for all calls to the sign functions to see the flow
rg -B 10 -A 10 'const signature = await sign' src/apps/perps/

Repository: pillarwallet/x

Length of output: 4326


🏁 Script executed:

# Check the SignedAction type definition
rg 'type SignedAction|interface SignedAction' -A 10 src/apps/perps/lib/hyperliquid/types.ts

Repository: pillarwallet/x

Length of output: 235


🏁 Script executed:

# Check builder.ts to see how signatures are used
cat -n src/apps/perps/lib/hyperliquid/builder.ts | head -100

Repository: pillarwallet/x

Length of output: 324


🏁 Script executed:

# Look for v normalization patterns or checks
rg 'v\s*[><=]|normalize.*v|v.*27|v.*28' src/apps/perps/lib/hyperliquid/

Repository: pillarwallet/x

Length of output: 437


🌐 Web query:

viem 2.37.1 signTypedData v encoding 0/1 or 27/28 recoverTypedDataAddress

💡 Result:

Short answer: viem's recoverTypedDataAddress accepts signatures with v encoded as 0/1 or 27/28 (and also handles the EIP‑2098 compact form). recoverTypedDataAddress simply hashes the typed data then calls recoverAddress, and recoverAddress normalizes the v/yParity formats before recovering the public address. Sources: viem docs and viem implementation + EIP‑2098 spec. [1][2][3]

Sources

  • viem recoverTypedDataAddress (docs). [1]
  • viem recoverTypedDataAddress implementation (calls recoverAddress). [2]
  • ERC‑2098 (compact signature / yParity 0/1 vs 27/28 explanation). [3]

🌐 Web query:

viem signTypedData return value v encoding format 0/1 or 27/28

💡 Result:

Viem returns a 0x‑prefixed hex signature (r||s||v or compact). Its signature parser accepts both v formats: 0/1 (yParity) or 27/28 — mapping 27→yParity 0 and 28→yParity 1. See parseSignature implementation. [1]

Sources:

  • viem parseSignature implementation. [1]

🌐 Web query:

viem walletClient.signTypedData signature format r s v hex

💡 Result:

Viem's walletClient.signTypedData returns a 0x-prefixed hex signature in the standard 65-byte (r, s, v) layout used by EIP-712: r (32 bytes) || s (32 bytes) || v (1 byte). The signature can be parsed like:

  • r = signature.slice(2, 66)
  • s = signature.slice(66, 130)
  • v = parseInt(signature.slice(130, 132), 16)

Note: some signers return v as 0/1 while others return 27/28 — if you need the 27/28 form add 27 when v is 0/1. Viem accepts/returns the hex signature for verifyTypedData and EIP‑712 signing. [1][2]

Sources:
[1] Viem signTypedData (walletClient) docs.
[2] EIP‑712 specification (typed data hashing & signing).


🌐 Web query:

Hyperliquid API signature format v 0/1 or 27/28 EIP-712

💡 Result:

Short answer: Hyperliquid uses EIP‑712 typed-data signatures. The signature is the usual r, s, v tuple (r: 32 bytes, s: 32 bytes, v: recovery id). Many SDKs/clients encode v as 27/28 (classic Ethereum) but some libraries/tooling represent the recovery id as 0/1 — both are the same recovery bit with a different offset. Use whichever representation your signer/library expects; the Hyperliquid servers accept the standard r/s plus recovery-id form.

Important protocol details:

  • Two signing schemes:
    • L1 Actions (trading): Domain name "Exchange", ChainId = 1337, action serialized with msgpack then hashed (phantom‑agent construction). Use the sign_l1_action flow. [1][4][2]
    • User‑signed actions (admin/transfers): Domain name "HyperliquidSignTransaction", ChainId = 0x66eee (421614 dec), version "1", sign the JSON typed data directly. [4][7]
  • Signature object format shown in docs: { "r": "0x...", "s": "0x...", "v": 27 } (v can be 27/28 or represented as 0/1 by client libraries). Ensure addresses are lowercased, msgpack field order and numeric trailing zeros match SDK expectations. [2][3][4]

Sources: [1] Chainstack Hyperliquid authentication guide; [2] Hyperliquid GitBook signing docs; [3] hyperliquid SDK signing utilities; [4] Chainstack Hyperliquid signing overview.


🏁 Script executed:

# Search for any v normalization or conversion in the codebase
rg 'v\s*\+\s*27|v\s*[<>]|yParity' src/apps/perps/lib/hyperliquid/

Repository: pillarwallet/x

Length of output: 40


🏁 Script executed:

# Check if there are any comments or docs about signature format
rg 'signature|sign|v.*parity|0/1|27/28' src/apps/perps/lib/hyperliquid/types.ts -A 2 -B 2

Repository: pillarwallet/x

Length of output: 148


Add explicit v normalization to ensure consistent signature format across signing, recovery, and API submission.

The code parses v directly from the signature bytes without normalizing to a canonical format (0/1 or 27/28). While viem's signTypedData can return v as either 0/1 (yParity) or 27/28 depending on the signer, and recoverTypedDataAddress handles both formats transparently, the parsed v is sent to the Hyperliquid API as-is. For consistency and to prevent subtle bugs if signers vary, explicitly normalize v to your chosen format (typically add 27 if v < 2):

const v = parseInt(signature.slice(130, 132), 16);
const normalizedV = v < 2 ? v + 27 : v; // Ensure 27/28 format

Apply to all functions in this file that parse signatures: signApproveAgentAction, signApproveBuilderFeeAction, signAgentAction, and signUserAction.

🤖 Prompt for AI Agents
In @src/apps/perps/lib/hyperliquid/signing.ts around lines 234 - 296, The
signature parsing extracts v directly from signature bytes but doesn't normalize
it, which can yield 0/1 or 27/28 depending on signer; update
signApproveAgentAction, signApproveBuilderFeeAction, signAgentAction, and
signUserAction to normalize v after parsing (if parsed v < 2 then add 27 to
produce 27/28) before returning or sending to the API so all code and API
submissions use a consistent v format.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In @src/apps/perps/lib/hyperliquid/client.ts:
- Around line 198-226: In getAllAssets: avoid overriding valid zero values by
replacing uses of `asset.szDecimals || 3` and `asset.maxLeverage || 50` with the
nullish coalescing operator (`asset.szDecimals ?? 3`, `asset.maxLeverage ??
50`), and ensure `response.data.universe` is an array before mapping (use
`Array.isArray(response.data.universe)`); if it's not an array, return an empty
list or handle accordingly so mapping only runs on a validated array.
- Around line 172-196: The getMarkPrice function currently uses a truthiness
check and parseFloat that treats 0 as falsy and can return NaN; change the logic
to explicitly check that response.data[symbol] is not null or undefined (use
markPrice !== undefined && markPrice !== null), convert it to a number safely
(e.g., Number(markPrice) or parseFloat), then validate the result with
Number.isFinite (or !Number.isNaN) and return the numeric value only when valid;
otherwise return null. Ensure references to markPrice, parseFloat/Number
conversion, and the getMarkPrice function are updated so 0 is preserved and
invalid parses yield null.
- Around line 94-147: Wrap and validate response.data with Array.isArray before
using array methods: in getOpenOrders replace const orders = response.data ||
[]; with something like const orders = Array.isArray(response.data) ?
response.data : []; and only call orders.filter when orders is an array (so
return [] if not); do the same in getFrontendOpenOrders to return
Array.isArray(response.data) ? response.data : []; this prevents runtime errors
if the API returns non-array payloads.
- Around line 5-11: The import-time evaluation of isTestnet (used to build
BASE_URL, EXCHANGE_URL, INFO_URL) reads localStorage and can crash in SSR/Node;
change isTestnet from an IIFE/constant to a runtime function (e.g., isTestnet())
in src/utils/blockchain.ts that guards access with typeof window !== 'undefined'
&& window.localStorage, then update this module to compute
BASE_URL/EXCHANGE_URL/INFO_URL at runtime (call isTestnet() inside the functions
or factory that use these URLs) so no localStorage access happens during module
import.
🧹 Nitpick comments (3)
src/apps/perps/lib/hyperliquid/client.ts (3)

12-51: Harden axios error handling + add timeouts (avoid any, preserve details)

catch (error: any) + error.response?.data is fragile for non-Axios errors, and requests have no timeout/abort (can hang UI). Prefer unknown + axios.isAxiosError, and centralize axios config.

Proposed refactor (axios instance + safer error parsing)
 import axios from 'axios';
 import { isTestnet } from '../../../../utils/blockchain';
 import type { SignedAction, UserState, AssetInfo, HyperliquidOrder, UniverseAsset, AssetContext } from './types';

 const BASE_URL = isTestnet
   ? 'https://api.hyperliquid-testnet.xyz'
   : 'https://api.hyperliquid.xyz';

-const EXCHANGE_URL = `${BASE_URL}/exchange`;
-const INFO_URL = `${BASE_URL}/info`;
+const http = axios.create({
+  baseURL: BASE_URL,
+  timeout: 15_000,
+  headers: { 'Content-Type': 'application/json' },
+});
+const EXCHANGE_URL = `/exchange`;
+const INFO_URL = `/info`;

 export async function postExchange(signedAction: SignedAction): Promise<any> {
   try {
     // Explicitly set vaultAddress to undefined if null/empty to exclude from JSON
     const payload = {
       ...signedAction,
       vaultAddress: signedAction.vaultAddress || undefined,
     };

-    const response = await axios.post(EXCHANGE_URL, payload, {
-      headers: {
-        'Content-Type': 'application/json',
-      },
-    });
+    const response = await http.post(EXCHANGE_URL, payload);
     const data = response.data;

     // Check if the exchange rejected the action
     if (data?.status && data.status !== 'ok') {
       const errorMsg =
         data.response?.error || data.response || 'Exchange rejected action';
       throw new Error(errorMsg);
     }

     return data;
-  } catch (error: any) {
-    console.error('Exchange API error:', error.response?.data || error.message);
+  } catch (error: unknown) {
+    const axiosData = axios.isAxiosError(error) ? error.response?.data : undefined;
+    const axiosMsg = axios.isAxiosError(error) ? error.message : undefined;
+    console.error('Exchange API error:', axiosData || axiosMsg || error);

     // Enhanced error message for signature mismatches
     const errorMessage =
-      error.response?.data?.error ||
-      error.message ||
+      (typeof axiosData === 'object' && axiosData && 'error' in axiosData ? (axiosData as any).error : undefined) ||
+      axiosMsg ||
+      (error instanceof Error ? error.message : undefined) ||
       'Failed to execute action';
     if (errorMessage.includes('does not exist')) {
       console.error(
         '⚠️ Likely EIP-712 typed data mismatch. Verify domain, types, primaryType, and chainId.'
       );
     }

     throw new Error(errorMessage);
   }
 }

53-92: DRY: factor a typed postInfo<T>() helper for INFO_URL calls

All info methods repeat the same axios boilerplate and error-shape assumptions; a postInfo<T>(payload) helper would reduce duplication and keep runtime guards consistent.


228-249: Consider typing getMetaAndAssetCtxs() instead of any

Since you already have UniverseAsset / AssetContext types imported, returning a typed tuple/object here would reduce downstream casting and runtime mistakes.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e448eea and ae0f838.

📒 Files selected for processing (1)
  • src/apps/perps/lib/hyperliquid/client.ts
🧰 Additional context used
🧬 Code graph analysis (1)
src/apps/perps/lib/hyperliquid/client.ts (2)
src/utils/blockchain.ts (1)
  • isTestnet (33-39)
src/apps/perps/lib/hyperliquid/types.ts (4)
  • SignedAction (62-71)
  • UserState (28-55)
  • HyperliquidOrder (106-125)
  • AssetInfo (73-78)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: lint
  • GitHub Check: unit-tests
  • GitHub Check: Cloudflare Pages: x
  • GitHub Check: build

Comment on lines +5 to +11
const BASE_URL = isTestnet
? 'https://api.hyperliquid-testnet.xyz'
: 'https://api.hyperliquid.xyz';

const EXCHANGE_URL = `${BASE_URL}/exchange`;
const INFO_URL = `${BASE_URL}/info`;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and examine the blockchain utility file
find . -name "blockchain.ts" -o -name "blockchain.js" | head -5

Repository: pillarwallet/x

Length of output: 249


🏁 Script executed:

# Also check the client file mentioned
find . -path "*/perps/lib/hyperliquid/client.ts" -o -path "*/perps/lib/hyperliquid/client.js"

Repository: pillarwallet/x

Length of output: 100


🏁 Script executed:

# List files in utils directory to understand structure
ls -la src/utils/ 2>/dev/null | head -20

Repository: pillarwallet/x

Length of output: 883


🏁 Script executed:

# Read the blockchain.ts file to understand isTestnet
cat -n src/utils/blockchain.ts | head -100

Repository: pillarwallet/x

Length of output: 3751


🏁 Script executed:

# Read the client.ts file to see the usage
cat -n src/apps/perps/lib/hyperliquid/client.ts | head -50

Repository: pillarwallet/x

Length of output: 2011


🏁 Script executed:

# Search for imports of the hyperliquid client
rg "from.*hyperliquid/client|import.*client.*hyperliquid" --type ts --type tsx

Repository: pillarwallet/x

Length of output: 85


🏁 Script executed:

# Also check if there are any SSR-related imports or server-side code
rg "hyperliquid" --type ts --type tsx | grep -i "server\|ssr\|node"

Repository: pillarwallet/x

Length of output: 85


🏁 Script executed:

# Check the package.json to see what kind of build/test setup is used
cat -n package.json | head -50

Repository: pillarwallet/x

Length of output: 2517


🏁 Script executed:

# Search for imports of hyperliquid/client (without tsx file type)
rg "from.*hyperliquid/client|import.*client.*hyperliquid"

Repository: pillarwallet/x

Length of output: 1151


🏁 Script executed:

# Also search more broadly for hyperliquid imports to see usage
rg "hyperliquid/client"

Repository: pillarwallet/x

Length of output: 1569


🏁 Script executed:

# Check if there are any test files for this module
find . -path "*/hyperliquid/**" -name "*.test.*" -o -path "*/hyperliquid/**" -name "*.spec.*"

Repository: pillarwallet/x

Length of output: 40


🏁 Script executed:

# Check if there are test files for the perps app
find . -path "*/perps/**" -name "*.test.*" -o -path "*/perps/**" -name "*.spec.*" | head -20

Repository: pillarwallet/x

Length of output: 40


🏁 Script executed:

# Check if perps/index.tsx is tested anywhere
rg "perps" --glob="*.test.*" --glob="*.spec.*" | head -20

Repository: pillarwallet/x

Length of output: 417


🏁 Script executed:

# Look for vitest config to understand test setup
find . -name "vitest.config.*" -o -name "vite.config.*" | xargs cat 2>/dev/null | head -80

Repository: pillarwallet/x

Length of output: 1121


🏁 Script executed:

# Check the test setup file
cat -n src/test-utils/setupTests.ts | head -50

Repository: pillarwallet/x

Length of output: 1948


🏁 Script executed:

# Also check if localStorage is mocked or available in tests
rg "localStorage" src/test-utils/ --context 2

Repository: pillarwallet/x

Length of output: 40


🏁 Script executed:

# Look for any imports of perps components in the main test setup or globally
rg "perps" src/test-utils/

Repository: pillarwallet/x

Length of output: 40


🏁 Script executed:

# Check if jsdom is being used and if localStorage is available
cat -n src/test-utils/setupTests.ts | tail -50

Repository: pillarwallet/x

Length of output: 2492


🏁 Script executed:

# Also verify whether isTestnet is actually used in any test context
rg "isTestnet" --type ts

Repository: pillarwallet/x

Length of output: 6571


🏁 Script executed:

# Let me verify the jsdom environment one more time and check if localStorage is actually available in the test setup
cat -n src/test-utils/setupTests.ts | grep -A5 -B5 "localStorage\|window\|global"

Repository: pillarwallet/x

Length of output: 2567


Refactor isTestnet to avoid module-load localStorage access for SSR/Node.js compatibility

isTestnet is evaluated at import time via an IIFE that calls localStorage.getItem(), which will crash if this client is imported in Node.js, SSR, or test environments that don't provide window or localStorage. Although the current jsdom test setup provides localStorage, this pattern is fragile and will break if:

  • Code is executed server-side or in Node.js CLI scripts
  • Test environment switches from jsdom to Node
  • Module is imported before DOM initialization

Refactor isTestnet in src/utils/blockchain.ts into a runtime function guarded by typeof window !== 'undefined', or ensure this client module is only imported client-side via dynamic imports.

🤖 Prompt for AI Agents
In @src/apps/perps/lib/hyperliquid/client.ts around lines 5 - 11, The
import-time evaluation of isTestnet (used to build BASE_URL, EXCHANGE_URL,
INFO_URL) reads localStorage and can crash in SSR/Node; change isTestnet from an
IIFE/constant to a runtime function (e.g., isTestnet()) in
src/utils/blockchain.ts that guards access with typeof window !== 'undefined' &&
window.localStorage, then update this module to compute
BASE_URL/EXCHANGE_URL/INFO_URL at runtime (call isTestnet() inside the functions
or factory that use these URLs) so no localStorage access happens during module
import.

Comment on lines +94 to +147
export async function getOpenOrders(
address: string,
symbol?: string
): Promise<HyperliquidOrder[]> {
try {
const response = await axios.post(
INFO_URL,
{
type: 'openOrders',
user: address,
},
{
headers: {
'Content-Type': 'application/json',
},
}
);

const orders = response.data || [];

// Filter by symbol if provided
if (symbol) {
return orders.filter((order: any) => order.coin === symbol);
}

return orders;
} catch (error: any) {
console.error('Open orders error:', error.response?.data || error.message);
return [];
}
}

export async function getFrontendOpenOrders(
address: string
): Promise<HyperliquidOrder[]> {
try {
const response = await axios.post(
INFO_URL,
{
type: 'frontendOpenOrders',
user: address,
},
{
headers: {
'Content-Type': 'application/json',
},
}
);
return response.data || [];
} catch (error: any) {
console.error('Frontend open orders error:', error.response?.data || error.message);
return [];
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and read the specific file
find . -type f -name "client.ts" | grep -E "hyperliquid|perps"

Repository: pillarwallet/x

Length of output: 100


🏁 Script executed:

# Let's also search for the file more broadly
fd "client.ts" | head -20

Repository: pillarwallet/x

Length of output: 98


🏁 Script executed:

# Check repository structure
git ls-files | grep -E "hyperliquid|perps" | head -30

Repository: pillarwallet/x

Length of output: 1395


🏁 Script executed:

# Read the specific file at the lines mentioned in the review
cat -n src/apps/perps/lib/hyperliquid/client.ts | sed -n '90,150p'

Repository: pillarwallet/x

Length of output: 1741


🏁 Script executed:

# Also check the full file to see type definitions and imports
wc -l src/apps/perps/lib/hyperliquid/client.ts

Repository: pillarwallet/x

Length of output: 102


🏁 Script executed:

# Check the beginning of the file for imports and type definitions
cat -n src/apps/perps/lib/hyperliquid/client.ts | head -40

Repository: pillarwallet/x

Length of output: 1660


🏁 Script executed:

# Search for HyperliquidOrder type definition
rg "type HyperliquidOrder|interface HyperliquidOrder" src/apps/perps/

Repository: pillarwallet/x

Length of output: 133


🏁 Script executed:

# Look for other API calls in the file to understand response patterns
rg "response\.data" src/apps/perps/lib/hyperliquid/client.ts -A 2 -B 2

Repository: pillarwallet/x

Length of output: 1307


🏁 Script executed:

# Check the HyperliquidOrder type definition
cat -n src/apps/perps/lib/hyperliquid/types.ts | grep -A 10 "export interface HyperliquidOrder"

Repository: pillarwallet/x

Length of output: 459


🏁 Script executed:

# Look for Hyperliquid API documentation or comments about response structure
rg "openOrders|frontendOpenOrders" src/apps/perps/ -B 2 -A 5

Repository: pillarwallet/x

Length of output: 18165


🏁 Script executed:

# Check how other functions handle similar responses - look for defensive patterns
cat -n src/apps/perps/lib/hyperliquid/client.ts | sed -n '160,210p'

Repository: pillarwallet/x

Length of output: 1460


🏁 Script executed:

# Check if there's any documentation or examples of what the Hyperliquid API returns
rg "openOrders|frontendOpenOrders" -B 5 -A 5 | head -60

Repository: pillarwallet/x

Length of output: 3445


🏁 Script executed:

# Look at the actual usage of these functions to see if there are any guards elsewhere
grep -r "getOpenOrders\|getFrontendOpenOrders" src/apps/perps/ --include="*.ts" --include="*.tsx" -B 2 -A 3

Repository: pillarwallet/x

Length of output: 6523


Add defensive Array.isArray() guards before array operations

getOpenOrders() calls .filter() on response.data without validating it's an array (line 112). Similarly, getFrontendOpenOrders() assumes response.data is an array when returning (line 142). While the Hyperliquid API currently returns arrays, defensive guards prevent runtime errors if the response structure changes or differs from expectations.

Proposed fix
-    const orders = response.data || [];
+    const orders: HyperliquidOrder[] = Array.isArray(response.data) ? response.data : [];

     // Filter by symbol if provided
     if (symbol) {
       return orders.filter((order: any) => order.coin === symbol);
     }

     return orders;
-    return response.data || [];
+    return Array.isArray(response.data) ? response.data : [];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function getOpenOrders(
address: string,
symbol?: string
): Promise<HyperliquidOrder[]> {
try {
const response = await axios.post(
INFO_URL,
{
type: 'openOrders',
user: address,
},
{
headers: {
'Content-Type': 'application/json',
},
}
);
const orders = response.data || [];
// Filter by symbol if provided
if (symbol) {
return orders.filter((order: any) => order.coin === symbol);
}
return orders;
} catch (error: any) {
console.error('Open orders error:', error.response?.data || error.message);
return [];
}
}
export async function getFrontendOpenOrders(
address: string
): Promise<HyperliquidOrder[]> {
try {
const response = await axios.post(
INFO_URL,
{
type: 'frontendOpenOrders',
user: address,
},
{
headers: {
'Content-Type': 'application/json',
},
}
);
return response.data || [];
} catch (error: any) {
console.error('Frontend open orders error:', error.response?.data || error.message);
return [];
}
}
export async function getOpenOrders(
address: string,
symbol?: string
): Promise<HyperliquidOrder[]> {
try {
const response = await axios.post(
INFO_URL,
{
type: 'openOrders',
user: address,
},
{
headers: {
'Content-Type': 'application/json',
},
}
);
const orders: HyperliquidOrder[] = Array.isArray(response.data) ? response.data : [];
// Filter by symbol if provided
if (symbol) {
return orders.filter((order: any) => order.coin === symbol);
}
return orders;
} catch (error: any) {
console.error('Open orders error:', error.response?.data || error.message);
return [];
}
}
export async function getFrontendOpenOrders(
address: string
): Promise<HyperliquidOrder[]> {
try {
const response = await axios.post(
INFO_URL,
{
type: 'frontendOpenOrders',
user: address,
},
{
headers: {
'Content-Type': 'application/json',
},
}
);
return Array.isArray(response.data) ? response.data : [];
} catch (error: any) {
console.error('Frontend open orders error:', error.response?.data || error.message);
return [];
}
}
🤖 Prompt for AI Agents
In @src/apps/perps/lib/hyperliquid/client.ts around lines 94 - 147, Wrap and
validate response.data with Array.isArray before using array methods: in
getOpenOrders replace const orders = response.data || []; with something like
const orders = Array.isArray(response.data) ? response.data : []; and only call
orders.filter when orders is an array (so return [] if not); do the same in
getFrontendOpenOrders to return Array.isArray(response.data) ? response.data :
[]; this prevents runtime errors if the API returns non-array payloads.

Comment on lines +172 to +196
export async function getMarkPrice(symbol: string): Promise<number | null> {
try {
const response = await axios.post(
INFO_URL,
{
type: 'allMids',
},
{
headers: {
'Content-Type': 'application/json',
},
}
);

if (response.data && typeof response.data === 'object') {
const markPrice = response.data[symbol];
return markPrice ? parseFloat(markPrice) : null;
}

return null;
} catch (error: any) {
console.error('Mark price error:', error.response?.data || error.message);
return null;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

getMarkPrice: avoid truthiness check and NaN leakage

return markPrice ? parseFloat(markPrice) : null; can mis-handle numeric 0 and can return NaN if parsing fails.

Proposed fix
-    if (response.data && typeof response.data === 'object') {
-      const markPrice = response.data[symbol];
-      return markPrice ? parseFloat(markPrice) : null;
+    if (response.data && typeof response.data === 'object' && response.data !== null) {
+      const raw = (response.data as Record<string, unknown>)[symbol];
+      const px = typeof raw === 'string' || typeof raw === 'number' ? Number(raw) : NaN;
+      return Number.isFinite(px) ? px : null;
     }
🤖 Prompt for AI Agents
In @src/apps/perps/lib/hyperliquid/client.ts around lines 172 - 196, The
getMarkPrice function currently uses a truthiness check and parseFloat that
treats 0 as falsy and can return NaN; change the logic to explicitly check that
response.data[symbol] is not null or undefined (use markPrice !== undefined &&
markPrice !== null), convert it to a number safely (e.g., Number(markPrice) or
parseFloat), then validate the result with Number.isFinite (or !Number.isNaN)
and return the numeric value only when valid; otherwise return null. Ensure
references to markPrice, parseFloat/Number conversion, and the getMarkPrice
function are updated so 0 is preserved and invalid parses yield null.

Comment on lines +198 to +226
export async function getAllAssets(): Promise<AssetInfo[]> {
try {
const response = await axios.post(
INFO_URL,
{
type: 'meta',
},
{
headers: {
'Content-Type': 'application/json',
},
}
);

if (response.data && response.data.universe) {
return response.data.universe.map((asset: any, index: number) => ({
id: index,
symbol: asset.name,
szDecimals: asset.szDecimals || 3,
maxLeverage: asset.maxLeverage || 50,
}));
}

return [];
} catch (error: any) {
console.error('Assets error:', error.response?.data || error.message);
return [];
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

getAllAssets: use ?? (don’t override valid 0) + validate universe array

asset.szDecimals || 3 and asset.maxLeverage || 50 will replace valid 0. Also, response.data.universe should be validated as an array before mapping.

Proposed fix
-    if (response.data && response.data.universe) {
-      return response.data.universe.map((asset: any, index: number) => ({
+    const universe = Array.isArray(response.data?.universe) ? response.data.universe : [];
+    if (universe.length) {
+      return universe.map((asset: any, index: number) => ({
         id: index,
         symbol: asset.name,
-        szDecimals: asset.szDecimals || 3,
-        maxLeverage: asset.maxLeverage || 50,
+        szDecimals: asset.szDecimals ?? 3,
+        maxLeverage: asset.maxLeverage ?? 50,
       }));
     }
🤖 Prompt for AI Agents
In @src/apps/perps/lib/hyperliquid/client.ts around lines 198 - 226, In
getAllAssets: avoid overriding valid zero values by replacing uses of
`asset.szDecimals || 3` and `asset.maxLeverage || 50` with the nullish
coalescing operator (`asset.szDecimals ?? 3`, `asset.maxLeverage ?? 50`), and
ensure `response.data.universe` is an array before mapping (use
`Array.isArray(response.data.universe)`); if it's not an array, return an empty
list or handle accordingly so mapping only runs on a validated array.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🤖 Fix all issues with AI agents
In @src/apps/perps/hooks/useHyperliquid.ts:
- Around line 189-207: Extract the duplicated wallet client creation into a
single async helper named createHyperliquidWalletClient(address, provider) that
returns a WalletClient|null: it should return null if no address, import
getImportedAccount from ../lib/hyperliquid/keystore and privateKeyToAccount from
viem/accounts when needed, compare importedAccount.accountAddress to address and
build createWalletClient({ account, chain: arbitrum, transport: custom(provider)
}) using the privateKey-derived account or the raw address otherwise; replace
the duplicated blocks in setupHyperliquid, checkSetupStatus, and
executeCopyTrade with calls to this helper and update any call sites to handle a
nullable return.
- Line 185: The checkSetupStatus callback closes over kit and provider but the
dependency array is empty, causing a stale closure; update the dependency array
for the useCallback (or the effect where checkSetupStatus is defined) to include
kit and provider so the function is recreated when those values change
(alternatively store provider in a ref if you must avoid re-running effects),
and then verify the useEffect that calls checkSetupStatus (around line ~390)
still behaves as intended when checkSetupStatus is recreated.
- Line 386: The dependency array for the executeCopyTrade callback is missing
provider, causing a stale closure; update the dependency array (currently
[address, setupStatus, loadBalance]) used by executeCopyTrade in
useHyperliquid.ts to include provider so the callback sees the latest provider
reference (also verify any other values used inside executeCopyTrade are present
in the array).
- Line 43: The walletClient state in the useHyperliquid hook is typed as any;
change it to a proper WalletClient type by importing the correct WalletClient
type from your wallet library and updating the state to useState<WalletClient |
null>(null). Update any places that call setWalletClient or read walletClient to
handle the nullable WalletClient type (e.g., add null checks or optional
chaining). Ensure the import uses a type-only import (import type { WalletClient
} from '...') to avoid runtime side effects.
- Around line 57-88: The code currently prints sensitive account information via
console.log for importedAccount.accountAddress, the EOA (eoa) and
client?.account?.address inside useHyperliquid; replace these direct logs by
either removing them or gating them behind a development-only check (e.g. only
log when process.env.NODE_ENV !== 'production' or a passed debug flag) and never
log private keys; if you must log addresses for debugging, log a masked form
(e.g. first 6 + last 4 chars) rather than the full value and ensure any existing
console.log statements referencing importedAccount.accountAddress, eoa, or
client?.account?.address are updated accordingly.
- Around line 341-345: The code is using hardcoded trading params (notional=5,
leverage=5, and roundToSzDecimals(..., 3)) which ignores user/tile settings and
asset.szDecimals; update the logic in the executeCopyTrade flow to source
notional and leverage from the CopyTile (e.g., tile.notional and tile.leverage)
or accept them as parameters to executeCopyTrade if not present on CopyTile, and
replace the fixed decimal 3 with asset.szDecimals when calling
roundToSzDecimals; ensure you update the CopyTile type to include
notional/leverage if needed and adjust callers to pass the values.

In @src/apps/perps/lib/hyperliquid/keystore.ts:
- Line 214: Imported account flows emit 'imported-account-changed' events but
agent wallet flows do not, causing inconsistent UI updates; update the agent
wallet code to dispatch a mirror event after changes: inside
storeAgentWalletEncrypted (after the successful store operation, e.g., where the
existing imported-account dispatch would occur) dispatch
window.dispatchEvent(new CustomEvent('agent-wallet-changed', { detail: {
masterAddress } })); and do the same in clearAgentWalletLocal (after clearing
the local agent wallet), using the same event name and detail shape so UI
listeners can handle agent wallet state changes consistently with
imported-account changes.
- Around line 253-262: getImportedAccountAddress currently calls JSON.parse on
localStorage.getItem(GLOBAL_ACCOUNT_KEY)! without error handling which will
throw on corrupted JSON; wrap the parse in a try-catch (or use a safeParse
helper) when LOCAL_ACCOUNT_KEY exists, extract accountAddress from the parsed
object only on successful parse, and return null (and optionally log or remove
the corrupted key) on parse failure so the function never throws. Use the
existing function name getImportedAccountAddress and the keys
GLOBAL_IMPORTED_ADDRESS_KEY and GLOBAL_ACCOUNT_KEY to locate where to add the
try-catch.
- Around line 55-57: The code assigns cachedPrivateKey and calls
resetInactivityTimer before confirming localStorage persistence; move the cache
population and timer reset so they occur only after all localStorage operations
(e.g., localStorage.setItem or whatever persistence functions used in this
block) complete successfully and without throwing. Wrap storage calls in a
try/catch, verify they succeeded, and only then set cachedPrivateKey =
privateKey and call resetInactivityTimer; on storage failure roll back any
partial state (don’t populate cache) and surface/log the error.
🧹 Nitpick comments (9)
src/apps/perps/lib/hyperliquid/keystore.ts (4)

16-23: Code duplication detected: generateAgentWallet exists in multiple files.

This function duplicates logic from src/apps/perps/lib/hyperliquid/signing.ts (lines 365-379). The implementation here using generatePrivateKey() from viem is preferable to the manual crypto approach in signing.ts. Consider removing the duplicate and importing from a single source.


280-286: Cache returns key without verifying it matches the stored address.

The cached key is returned based solely on the presence of an address in storage (lines 280-285), without verifying that the cached key actually corresponds to that address. If localStorage is manipulated between caching and retrieval, the wrong key could be returned for an address.

While an attacker with localStorage access has broader capabilities, defensive programming suggests verifying the match. Consider deriving the address from the cached key and comparing it to the stored address before returning.


357-383: Ambiguous null return: Can't distinguish "no wallet" from "wallet locked".

getAgentWallet returns null for both scenarios: when no wallet exists and when a wallet is encrypted but not unlocked (lines 373-379). Callers cannot distinguish between these states, which may require different UI flows (setup vs. unlock).

Consider returning a status indicator or using different return types:

type WalletState = 
  | { status: 'unlocked'; wallet: { address: string; privateKey: Hex; approved: boolean; builderApproved: boolean } }
  | { status: 'locked' }
  | { status: 'none' };

export async function getAgentWalletState(masterAddress: string): Promise<WalletState> {
  // Check cache...
  if (cachedPrivateKey) {
    // return { status: 'unlocked', wallet: {...} }
  }
  
  if (isAgentWalletEncrypted(masterAddress)) {
    return { status: 'locked' };
  }
  
  const legacy = getAgentWalletLocal(masterAddress);
  if (legacy) {
    return { status: 'unlocked', wallet: legacy };
  }
  
  return { status: 'none' };
}

405-408: Function declared async but performs no asynchronous operations.

checkWalletMatch is declared as async but contains only synchronous operations. While this doesn't cause functional issues, it creates unnecessary Promise overhead.

Remove async if not needed
-export async function checkWalletMatch(importedAddress: string, masterAddress: string): Promise<boolean> {
+export function checkWalletMatch(importedAddress: string, masterAddress: string): boolean {
   // Normalize addresses for comparison
   const normalize = (addr: string) => addr.toLowerCase();
   return normalize(importedAddress) === normalize(getAgentAddress(masterAddress) || '');
 }

Note: Keep async if you anticipate future changes that will require asynchronous operations.

src/apps/perps/hooks/useHyperliquid.ts (5)

89-90: Remove duplicate comment.

The comment "Always fetch assets on load" appears twice on consecutive lines.

♻️ Proposed fix
-    // Always fetch assets on load
     // Always fetch assets on load

145-158: Remove commented code or document with TODO.

This large commented block (14 lines) for agent address switching should either be removed if no longer needed, or documented with a TODO comment explaining why it's disabled and when it will be re-enabled.

♻️ Proposed fix

If the feature is planned:

+      // TODO: Re-enable agent address switching after testing
       // 2. Check Agent Address
       // Unconditional switch: If an agent is linked, we use it.

If the feature is not needed:

-      // 2. Check Agent Address
-      // Unconditional switch: If an agent is linked, we use it.
-      // const agentAddress = getAgentAddress(eoa);
-      // if (agentAddress) {
-      //   console.log(
-      //     'DEBUG: Found Agent Address, executing switch:',
-      //     agentAddress
-      //   );
-
-      //   const agentState = await getUserState(agentAddress);
-      //   const agentOrders = await getOpenOrders(agentAddress);
-
-      //   targetAddress = agentAddress as string;
-      //   state = agentState;
-      //   orders = agentOrders;
-      // }

248-260: Remove commented code or document with TODO.

Another large commented block for agent address logic. Apply the same guidance as the previous commented block in checkSetupStatus.


47-185: Consider refactoring for better separation of concerns.

The checkSetupStatus function is 138 lines and handles multiple responsibilities:

  1. Wallet client creation
  2. Asset fetching and parsing
  3. User state retrieval
  4. Setup status determination

Consider breaking this into smaller, focused functions:

  • resolveActiveWallet() - determine and create wallet client
  • fetchAvailableAssets() - load and parse asset data
  • checkUserSetupStatus(address) - verify user state

This would improve testability, readability, and maintainability.


45-45: Consider validating provider before use.

The provider is obtained from kit.getProvider() but is never validated for null or undefined before being used in wallet client creation (lines 67, 81, 199, 205, 303, 309). If the provider is unavailable, this will cause runtime errors when attempting to create wallet clients.

♻️ Proposed fix

Add validation in functions that use the provider:

 const setupHyperliquid = useCallback(async () => {
+    if (!provider) {
+      toast.error('Wallet provider not available');
+      return;
+    }
     // ... rest of function

Or add validation once in the extracted createHyperliquidWalletClient helper function if you implement that refactor.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ae0f838 and 1e63d6b.

📒 Files selected for processing (2)
  • src/apps/perps/hooks/useHyperliquid.ts
  • src/apps/perps/lib/hyperliquid/keystore.ts
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-11-21T13:10:33.422Z
Learnt from: aldin4u
Repo: pillarwallet/x PR: 461
File: src/apps/pulse/components/Search/MarketList.tsx:10-15
Timestamp: 2025-11-21T13:10:33.422Z
Learning: In the Pulse app's MarketList component (src/apps/pulse/components/Search/MarketList.tsx), markets should display liquidity (not price) in the right-hand column. This is per the design specification.

Applied to files:

  • src/apps/perps/hooks/useHyperliquid.ts
📚 Learning: 2026-01-07T12:27:46.789Z
Learnt from: vignesha22
Repo: pillarwallet/x PR: 486
File: src/apps/pulse/components/Onboarding/TopUpScreen.tsx:1005-1049
Timestamp: 2026-01-07T12:27:46.789Z
Learning: In the Pulse app TopUpScreen gasless flow (src/apps/pulse/components/Onboarding/TopUpScreen.tsx), when the Gasless Fee Estimate UI is displayed, the fee token (selectedFeeAsset) is always the same as the topup token (selectedToken) because selectedFeeAsset is only set when a matching paymaster is found where gasToken matches selectedToken.address (lines 377-391).

Applied to files:

  • src/apps/perps/hooks/useHyperliquid.ts
📚 Learning: 2025-03-28T09:22:22.712Z
Learnt from: RanaBug
Repo: pillarwallet/x PR: 275
File: src/apps/the-exchange/components/DropdownTokensList/DropdownTokenList.tsx:180-195
Timestamp: 2025-03-28T09:22:22.712Z
Learning: In the Exchange app, `swapTokenList` and `receiveTokenList` are derived from `searchTokenResult` when search is active, so including `searchToken` in the useEffect dependency array that uses these lists would be redundant as the lists will update when search results change.

Applied to files:

  • src/apps/perps/hooks/useHyperliquid.ts
📚 Learning: 2025-11-04T14:34:00.373Z
Learnt from: RanaBug
Repo: pillarwallet/x PR: 445
File: src/containers/Authorized.tsx:63-77
Timestamp: 2025-11-04T14:34:00.373Z
Learning: In src/containers/Authorized.tsx, the tempKit useEffect intentionally excludes chainId from its dependency array because the kit is used by useWalletModeVerification to check wallet mode across all supported chains, regardless of the currently selected chainId. The chainId parameter is only used for initial kit configuration and does not affect the multi-chain verification results.

Applied to files:

  • src/apps/perps/hooks/useHyperliquid.ts
📚 Learning: 2025-08-12T07:42:24.656Z
Learnt from: IAmKio
Repo: pillarwallet/x PR: 351
File: src/apps/pulse/utils/intent.ts:44-53
Timestamp: 2025-08-12T07:42:24.656Z
Learning: In the Pulse app's intent utilities (src/apps/pulse/utils/intent.ts), the team has chosen to use floating-point arithmetic for token amount calculations despite potential precision issues, accepting JavaScript's decimal place limitations as a valid trade-off for their use case.

Applied to files:

  • src/apps/perps/hooks/useHyperliquid.ts
🧬 Code graph analysis (2)
src/apps/perps/hooks/useHyperliquid.ts (6)
src/apps/perps/lib/hyperliquid/types.ts (4)
  • UserState (28-55)
  • HyperliquidOrder (106-125)
  • EnhancedAsset (80-85)
  • CopyTile (20-26)
src/apps/perps/lib/hyperliquid/keystore.ts (1)
  • getImportedAccount (273-308)
src/apps/perps/lib/hyperliquid/client.ts (5)
  • getMetaAndAssetCtxs (228-249)
  • getAllAssets (198-226)
  • getUserState (53-72)
  • getOpenOrders (94-124)
  • postExchange (12-51)
src/apps/perps/lib/hyperliquid/signing.ts (2)
  • buildNoopAction (94-98)
  • signUserAction (29-92)
src/apps/perps/components/CopyTile.tsx (1)
  • CopyTile (21-137)
src/apps/perps/lib/hyperliquid/math.ts (3)
  • getEntryPrice (18-23)
  • calculatePositionSize (1-8)
  • roundToSzDecimals (10-16)
src/apps/perps/lib/hyperliquid/keystore.ts (3)
src/apps/perps/lib/hyperliquid/session-manager.ts (3)
  • initializeSessionManager (19-22)
  • resetInactivityTimer (28-41)
  • clearSession (47-58)
src/apps/perps/lib/hyperliquid/signing.ts (1)
  • generateAgentWallet (366-380)
src/apps/perps/lib/encryption.ts (3)
  • encryptWithPin (50-72)
  • EncryptedData (43-47)
  • decryptWithPin (75-97)
🪛 ast-grep (0.40.4)
src/apps/perps/lib/hyperliquid/keystore.ts

[warning] 45-45: Sensitive information detected in localStorage. Storing sensitive data like emails, usernames, or personal information in localStorage exposes it to malicious scripts and XSS attacks. Use secure storage alternatives or avoid storing sensitive data client-side.
Context: localStorage.setItem(getStorageKey(masterAddress, 'address'), address)
Note: [CWE-312] Cleartext Storage of Sensitive Information [REFERENCES]
- https://cwe.mitre.org/data/definitions/312.html
- https://owasp.org/www-community/vulnerabilities/HTML5_Local_Storage_Security

(local-storage-sensitive-information)


[warning] 130-130: Sensitive information detected in localStorage. Storing sensitive data like emails, usernames, or personal information in localStorage exposes it to malicious scripts and XSS attacks. Use secure storage alternatives or avoid storing sensitive data client-side.
Context: localStorage.setItem(getStorageKey(masterAddress, 'address'), address)
Note: [CWE-312] Cleartext Storage of Sensitive Information [REFERENCES]
- https://cwe.mitre.org/data/definitions/312.html
- https://owasp.org/www-community/vulnerabilities/HTML5_Local_Storage_Security

(local-storage-sensitive-information)


[warning] 204-204: Sensitive information detected in localStorage. Storing sensitive data like emails, usernames, or personal information in localStorage exposes it to malicious scripts and XSS attacks. Use secure storage alternatives or avoid storing sensitive data client-side.
Context: localStorage.setItem(GLOBAL_IMPORTED_ADDRESS_KEY, address)
Note: [CWE-312] Cleartext Storage of Sensitive Information [REFERENCES]
- https://cwe.mitre.org/data/definitions/312.html
- https://owasp.org/www-community/vulnerabilities/HTML5_Local_Storage_Security

(local-storage-sensitive-information)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: lint
  • GitHub Check: unit-tests
  • GitHub Check: build
  • GitHub Check: Cloudflare Pages: x
🔇 Additional comments (3)
src/apps/perps/lib/hyperliquid/keystore.ts (2)

64-108: LGTM: Well-implemented unlock with defensive validation.

The defensive validation of boolean values (lines 74-77, 81-84) prevents type errors from corrupted data. The fallback to legacy plaintext storage is appropriate for migration support, and error handling properly delegates PIN validation to the UI.


10-14: This concern doesn't apply to a client-side bundled application.

The keystore module is client-side only with no server-side rendering detected. In bundled JavaScript applications, module code executes once at load time regardless of how many files import it. The dynamic imports in useHyperliquid.ts load the already-initialized module. Even if initializeSessionManager() were called multiple times, the session manager safely handles it by overwriting the callback and resetting the inactivity timer.

Likely an incorrect or invalid review comment.

src/apps/perps/hooks/useHyperliquid.ts (1)

398-398: The custom event dispatch is already consistent. All code paths that modify the imported account state (storeImportedAccountEncrypted at line 214 and clearImportedAccount at line 317 in keystore.ts) dispatch the 'imported-account-changed' event. The unlockImportedAccount function does not dispatch because it only populates the in-memory cache without modifying stored state—this is the correct behavior.

Likely an incorrect or invalid review comment.

const [isLoading, setIsLoading] = useState(false);
const [activeAddress, setActiveAddress] = useState<string | null>(null);
const [address, setAddress] = useState<string | null>(null);
const [walletClient, setWalletClient] = useState<any>(null);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Replace any type with proper WalletClient type.

The walletClient state uses any, which bypasses TypeScript's type checking and can lead to runtime errors.

♻️ Proposed fix
+import type { WalletClient } from 'viem';
+
-  const [walletClient, setWalletClient] = useState<any>(null);
+  const [walletClient, setWalletClient] = useState<WalletClient | null>(null);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [walletClient, setWalletClient] = useState<any>(null);
import type { WalletClient } from 'viem';
const [walletClient, setWalletClient] = useState<WalletClient | null>(null);
🤖 Prompt for AI Agents
In @src/apps/perps/hooks/useHyperliquid.ts at line 43, The walletClient state in
the useHyperliquid hook is typed as any; change it to a proper WalletClient type
by importing the correct WalletClient type from your wallet library and updating
the state to useState<WalletClient | null>(null). Update any places that call
setWalletClient or read walletClient to handle the nullable WalletClient type
(e.g., add null checks or optional chaining). Ensure the import uses a type-only
import (import type { WalletClient } from '...') to avoid runtime side effects.

Comment on lines +57 to +88
console.log('DEBUG: Using Imported Account:', importedAccount.accountAddress);
targetAddress = importedAccount.accountAddress;

// Create WalletClient from Private Key
const { privateKeyToAccount } = await import('viem/accounts');
const account = privateKeyToAccount(importedAccount.privateKey as `0x${string}`);

client = createWalletClient({
account,
chain: arbitrum,
transport: custom(provider as any),
});
isImported = true;
} else {
// 2. Fallback to Connected Wallet
const walletProvider = kit.getEtherspotProvider();
const eoa = (await walletProvider.getSdk()).getEOAAddress() || null;
console.log('DEBUG: Wallet Provider EOA:', eoa);

if (eoa) {
targetAddress = eoa;
client = createWalletClient({
account: eoa as `0x${string}`,
chain: arbitrum,
transport: custom(provider as any),
});
}
}

setAddress(targetAddress);
setWalletClient(client);
console.log('DEBUG: Active Client Account:', client?.account?.address);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove or protect debug logging that exposes PII.

Multiple console.log statements expose sensitive information like account addresses and private keys in use. These should be removed in production or wrapped in a development-only condition.

Lines with PII exposure:

  • Line 57: Logs imported account address
  • Line 74: Logs EOA address
  • Line 88: Logs client account address
♻️ Proposed fix
-      console.log('DEBUG: Using Imported Account:', importedAccount.accountAddress);
+      if (process.env.NODE_ENV === 'development') {
+        console.log('DEBUG: Using Imported Account');
+      }
...
-      console.log('DEBUG: Wallet Provider EOA:', eoa);
+      if (process.env.NODE_ENV === 'development') {
+        console.log('DEBUG: Wallet Provider EOA');
+      }
...
-    console.log('DEBUG: Active Client Account:', client?.account?.address);
+    if (process.env.NODE_ENV === 'development') {
+      console.log('DEBUG: Active Client Account');
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log('DEBUG: Using Imported Account:', importedAccount.accountAddress);
targetAddress = importedAccount.accountAddress;
// Create WalletClient from Private Key
const { privateKeyToAccount } = await import('viem/accounts');
const account = privateKeyToAccount(importedAccount.privateKey as `0x${string}`);
client = createWalletClient({
account,
chain: arbitrum,
transport: custom(provider as any),
});
isImported = true;
} else {
// 2. Fallback to Connected Wallet
const walletProvider = kit.getEtherspotProvider();
const eoa = (await walletProvider.getSdk()).getEOAAddress() || null;
console.log('DEBUG: Wallet Provider EOA:', eoa);
if (eoa) {
targetAddress = eoa;
client = createWalletClient({
account: eoa as `0x${string}`,
chain: arbitrum,
transport: custom(provider as any),
});
}
}
setAddress(targetAddress);
setWalletClient(client);
console.log('DEBUG: Active Client Account:', client?.account?.address);
if (process.env.NODE_ENV === 'development') {
console.log('DEBUG: Using Imported Account');
}
targetAddress = importedAccount.accountAddress;
// Create WalletClient from Private Key
const { privateKeyToAccount } = await import('viem/accounts');
const account = privateKeyToAccount(importedAccount.privateKey as `0x${string}`);
client = createWalletClient({
account,
chain: arbitrum,
transport: custom(provider as any),
});
isImported = true;
} else {
// 2. Fallback to Connected Wallet
const walletProvider = kit.getEtherspotProvider();
const eoa = (await walletProvider.getSdk()).getEOAAddress() || null;
if (process.env.NODE_ENV === 'development') {
console.log('DEBUG: Wallet Provider EOA');
}
if (eoa) {
targetAddress = eoa;
client = createWalletClient({
account: eoa as `0x${string}`,
chain: arbitrum,
transport: custom(provider as any),
});
}
}
setAddress(targetAddress);
setWalletClient(client);
if (process.env.NODE_ENV === 'development') {
console.log('DEBUG: Active Client Account');
}
🤖 Prompt for AI Agents
In @src/apps/perps/hooks/useHyperliquid.ts around lines 57 - 88, The code
currently prints sensitive account information via console.log for
importedAccount.accountAddress, the EOA (eoa) and client?.account?.address
inside useHyperliquid; replace these direct logs by either removing them or
gating them behind a development-only check (e.g. only log when
process.env.NODE_ENV !== 'production' or a passed debug flag) and never log
private keys; if you must log addresses for debugging, log a masked form (e.g.
first 6 + last 4 chars) rather than the full value and ensure any existing
console.log statements referencing importedAccount.accountAddress, eoa, or
client?.account?.address are updated accordingly.

} finally {
setIsLoading(false);
}
}, []);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix stale closure issue in dependency array.

The checkSetupStatus callback uses kit and provider (line 45, 72) but the dependency array is empty. This creates a stale closure where the function captures the initial values and won't update if kit changes. This can lead to using outdated provider instances.

🐛 Proposed fix
-  }, []);
+  }, [kit, provider]);

Note: This will cause checkSetupStatus to be recreated when kit changes, which may trigger the useEffect on line 390. Verify this behavior is acceptable, or consider using useRef to store the provider if you want to avoid re-running the effect.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
}, []);
}, [kit, provider]);
🤖 Prompt for AI Agents
In @src/apps/perps/hooks/useHyperliquid.ts at line 185, The checkSetupStatus
callback closes over kit and provider but the dependency array is empty, causing
a stale closure; update the dependency array for the useCallback (or the effect
where checkSetupStatus is defined) to include kit and provider so the function
is recreated when those values change (alternatively store provider in a ref if
you must avoid re-running effects), and then verify the useEffect that calls
checkSetupStatus (around line ~390) still behaves as intended when
checkSetupStatus is recreated.

Comment on lines +189 to +207
const { getImportedAccount } = await import('../lib/hyperliquid/keystore');
const importedAccount = getImportedAccount();

let client;
if (importedAccount && importedAccount.accountAddress === address) {
const { privateKeyToAccount } = await import('viem/accounts');
const account = privateKeyToAccount(importedAccount.privateKey as `0x${string}`);
client = createWalletClient({
account,
chain: arbitrum,
transport: custom(provider as any),
});
} else {
client = createWalletClient({
account: address as `0x${string}`,
chain: arbitrum,
transport: custom(provider as any),
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Extract duplicated wallet client creation logic.

The wallet client creation logic (checking imported account vs connected wallet) is duplicated across setupHyperliquid (lines 189-207), checkSetupStatus (lines 48-84), and executeCopyTrade (lines 293-311). This makes the code harder to maintain and increases the risk of inconsistencies.

♻️ Proposed refactor

Create a shared helper function:

async function createHyperliquidWalletClient(
  address: string | null,
  provider: any
): Promise<WalletClient | null> {
  if (!address) return null;

  const { getImportedAccount } = await import('../lib/hyperliquid/keystore');
  const importedAccount = getImportedAccount();

  if (importedAccount && importedAccount.accountAddress === address) {
    const { privateKeyToAccount } = await import('viem/accounts');
    const account = privateKeyToAccount(importedAccount.privateKey as `0x${string}`);
    return createWalletClient({
      account,
      chain: arbitrum,
      transport: custom(provider),
    });
  }

  return createWalletClient({
    account: address as `0x${string}`,
    chain: arbitrum,
    transport: custom(provider),
  });
}

Then use it in all three places:

-    const { getImportedAccount } = await import('../lib/hyperliquid/keystore');
-    const importedAccount = getImportedAccount();
-    // ... 15+ lines of duplicate logic
-    const walletClient = client;
+    const walletClient = await createHyperliquidWalletClient(address, provider);
🤖 Prompt for AI Agents
In @src/apps/perps/hooks/useHyperliquid.ts around lines 189 - 207, Extract the
duplicated wallet client creation into a single async helper named
createHyperliquidWalletClient(address, provider) that returns a
WalletClient|null: it should return null if no address, import
getImportedAccount from ../lib/hyperliquid/keystore and privateKeyToAccount from
viem/accounts when needed, compare importedAccount.accountAddress to address and
build createWalletClient({ account, chain: arbitrum, transport: custom(provider)
}) using the privateKey-derived account or the raw address otherwise; replace
the duplicated blocks in setupHyperliquid, checkSetupStatus, and
executeCopyTrade with calls to this helper and update any call sites to handle a
nullable return.

} finally {
setIsLoading(false);
}
}, [address, checkSetupStatus]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix stale closure issue in dependency array.

Similar to checkSetupStatus, this function uses provider (line 199, 205) and kit (indirectly through provider) but they're missing from the dependency array. This can cause the function to use stale provider instances.

🐛 Proposed fix
-  }, [address, checkSetupStatus]);
+  }, [address, checkSetupStatus, provider]);

Alternatively, if you extract wallet client creation as suggested in the previous comment, the extracted function can handle the provider dependency directly.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
}, [address, checkSetupStatus]);
}, [address, checkSetupStatus, provider]);

setIsLoading(false);
}
},
[address, setupStatus, loadBalance]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix stale closure issue in dependency array.

Similar to the other functions, executeCopyTrade uses provider (lines 303, 309) but it's missing from the dependency array, creating a stale closure risk.

🐛 Proposed fix
-  }, [address, setupStatus, loadBalance]);
+  }, [address, setupStatus, loadBalance, provider]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[address, setupStatus, loadBalance]
}, [address, setupStatus, loadBalance, provider]);
🤖 Prompt for AI Agents
In @src/apps/perps/hooks/useHyperliquid.ts at line 386, The dependency array for
the executeCopyTrade callback is missing provider, causing a stale closure;
update the dependency array (currently [address, setupStatus, loadBalance]) used
by executeCopyTrade in useHyperliquid.ts to include provider so the callback
sees the latest provider reference (also verify any other values used inside
executeCopyTrade are present in the array).

Comment on lines +55 to +57
// Hot-load the cache so it's immediately available without unlocking again
cachedPrivateKey = privateKey;
resetInactivityTimer(); // Reset session timeout on wallet storage
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Cache populated before verifying localStorage operations succeeded.

The cache is updated immediately after encryption (line 56) but before verifying that all localStorage operations completed successfully. If a localStorage operation fails later (e.g., due to quota limits), the cache would contain a key that isn't actually stored, leading to inconsistent state.

🔄 Suggested fix: Populate cache after storage confirmation
   localStorage.setItem(getStorageKey(masterAddress, 'approved'), String(approved));
   localStorage.setItem(getStorageKey(masterAddress, 'builder_approved'), String(builderApproved));

+  // All storage operations succeeded, now populate cache
   cachedPrivateKey = privateKey;
   resetInactivityTimer(); // Reset session timeout on wallet storage
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Hot-load the cache so it's immediately available without unlocking again
cachedPrivateKey = privateKey;
resetInactivityTimer(); // Reset session timeout on wallet storage
// Hot-load the cache so it's immediately available without unlocking again
// All storage operations succeeded, now populate cache
cachedPrivateKey = privateKey;
resetInactivityTimer(); // Reset session timeout on wallet storage
🤖 Prompt for AI Agents
In @src/apps/perps/lib/hyperliquid/keystore.ts around lines 55 - 57, The code
assigns cachedPrivateKey and calls resetInactivityTimer before confirming
localStorage persistence; move the cache population and timer reset so they
occur only after all localStorage operations (e.g., localStorage.setItem or
whatever persistence functions used in this block) complete successfully and
without throwing. Wrap storage calls in a try/catch, verify they succeeded, and
only then set cachedPrivateKey = privateKey and call resetInactivityTimer; on
storage failure roll back any partial state (don’t populate cache) and
surface/log the error.

Comment on lines +123 to +141
export function storeAgentWalletLocal(
masterAddress: string,
address: string,
privateKey: Hex,
approved?: boolean
): void {
if (typeof window === 'undefined') return;

localStorage.setItem(getStorageKey(masterAddress, 'address'), address);
localStorage.setItem(getStorageKey(masterAddress, 'key'), privateKey);
if (approved !== undefined) {
localStorage.setItem(
getStorageKey(masterAddress, 'approved'),
String(approved)
);
}
// Clear cache and session on new plaintext storage
clearSession();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, find the keystore.ts file and read it to see the full context
find . -name "keystore.ts" -type f | head -5

Repository: pillarwallet/x

Length of output: 102


🏁 Script executed:

#!/bin/bash
# Check for usages of the legacy plaintext storage functions
rg -n --type=ts --type=tsx "storeAgentWalletLocal|getAgentWalletLocal" -C 3

Repository: pillarwallet/x

Length of output: 85


🏁 Script executed:

#!/bin/bash
# Look for encrypted storage alternatives and deprecation notices
rg -n --type=ts --type=tsx "encrypted|Encrypted|crypto|encryption" src/apps/perps/lib/hyperliquid/ -C 2

Repository: pillarwallet/x

Length of output: 85


🏁 Script executed:

#!/bin/bash
# Check for any deprecation comments or warnings in the keystore file
rg -n --type=ts --type=tsx "@deprecated|DEPRECATED|Legacy|LEGACY" src/apps/perps/lib/hyperliquid/keystore.ts -C 3

Repository: pillarwallet/x

Length of output: 85


🏁 Script executed:

#!/bin/bash
# Read the keystore.ts file to see the full context
wc -l ./src/apps/perps/lib/hyperliquid/keystore.ts

Repository: pillarwallet/x

Length of output: 106


🏁 Script executed:

#!/bin/bash
# Read the keystore.ts file
cat -n ./src/apps/perps/lib/hyperliquid/keystore.ts

Repository: pillarwallet/x

Length of output: 17579


🏁 Script executed:

#!/bin/bash
# Search for usages of legacy functions without type flag
rg -n "storeAgentWalletLocal|getAgentWalletLocal" -C 3

Repository: pillarwallet/x

Length of output: 2084


🏁 Script executed:

#!/bin/bash
# Look for encryption/deprecated markers in keystore
rg -n "deprecated|Deprecated|LEGACY|Legacy|encrypted|Encrypted" ./src/apps/perps/lib/hyperliquid/keystore.ts -C 2

Repository: pillarwallet/x

Length of output: 4727


🏁 Script executed:

#!/bin/bash
# Search for calls to storeAgentWalletLocal outside keystore.ts
rg "storeAgentWalletLocal" --type ts | grep -v "keystore.ts"

Repository: pillarwallet/x

Length of output: 40


🏁 Script executed:

#!/bin/bash
# Also check for getAgentWalletLocal calls outside keystore.ts
rg "getAgentWalletLocal" --type ts | grep -v "keystore.ts"

Repository: pillarwallet/x

Length of output: 40


Legacy plaintext storage still accessible—confirm migration is complete before removal.

The storeAgentWalletLocal function (line 132) stores private keys in plaintext in localStorage, creating XSS vulnerability. While already marked deprecated (line 121, 343), these functions remain exported and accessible as fallback paths in unlockAgentWallet (line 88) and getAgentWallet (line 382). No external callers were found in the codebase, suggesting migration to encrypted storage may be complete.

Verify migration status:

  • If legacy plaintext data still exists in user localStorage: continue supporting fallback but add explicit migration warnings
  • If all data has been migrated to encrypted storage: remove these functions entirely to eliminate the security surface

The encrypted alternative (storeAgentWalletEncrypted with PIN-based encryption) is already the primary path and actively clears legacy plaintext on upgrade (line 49-50).

🧰 Tools
🪛 ast-grep (0.40.4)

[warning] 130-130: Sensitive information detected in localStorage. Storing sensitive data like emails, usernames, or personal information in localStorage exposes it to malicious scripts and XSS attacks. Use secure storage alternatives or avoid storing sensitive data client-side.
Context: localStorage.setItem(getStorageKey(masterAddress, 'address'), address)
Note: [CWE-312] Cleartext Storage of Sensitive Information [REFERENCES]
- https://cwe.mitre.org/data/definitions/312.html
- https://owasp.org/www-community/vulnerabilities/HTML5_Local_Storage_Security

(local-storage-sensitive-information)

// Cache it
cachedImportedKey = privateKey;
resetInactivityTimer(); // Reset session timeout on imported account storage
window.dispatchEvent(new Event('imported-account-changed'));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Inconsistent event dispatching between agent wallets and imported accounts.

Imported account operations dispatch 'imported-account-changed' events (lines 214, 317), but agent wallet operations don't dispatch corresponding events. This inconsistency could cause UI components to miss agent wallet state changes.

Consider adding event dispatching to agent wallet operations for consistent state management:

// In storeAgentWalletEncrypted, after line 57
window.dispatchEvent(new CustomEvent('agent-wallet-changed', { 
  detail: { masterAddress } 
}));

// In clearAgentWalletLocal, after line 181
window.dispatchEvent(new CustomEvent('agent-wallet-changed', { 
  detail: { masterAddress } 
}));

Also applies to: 317-317

🤖 Prompt for AI Agents
In @src/apps/perps/lib/hyperliquid/keystore.ts at line 214, Imported account
flows emit 'imported-account-changed' events but agent wallet flows do not,
causing inconsistent UI updates; update the agent wallet code to dispatch a
mirror event after changes: inside storeAgentWalletEncrypted (after the
successful store operation, e.g., where the existing imported-account dispatch
would occur) dispatch window.dispatchEvent(new
CustomEvent('agent-wallet-changed', { detail: { masterAddress } })); and do the
same in clearAgentWalletLocal (after clearing the local agent wallet), using the
same event name and detail shape so UI listeners can handle agent wallet state
changes consistently with imported-account changes.

Comment on lines +253 to +262
export function getImportedAccountAddress(): string | null {
if (typeof window === 'undefined') return null;
// Check new key first, then legacy
return (
localStorage.getItem(GLOBAL_IMPORTED_ADDRESS_KEY) ||
(localStorage.getItem(GLOBAL_ACCOUNT_KEY)
? JSON.parse(localStorage.getItem(GLOBAL_ACCOUNT_KEY)!).accountAddress
: null)
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unsafe JSON parsing could throw unhandled exception.

Line 259 uses JSON.parse with a non-null assertion operator without wrapping in try-catch. If GLOBAL_ACCOUNT_KEY contains corrupted JSON data, this will throw an unhandled exception and crash the function.

🛡️ Proposed fix: Add try-catch for safe parsing
 export function getImportedAccountAddress(): string | null {
   if (typeof window === 'undefined') return null;
-  // Check new key first, then legacy
-  return (
-    localStorage.getItem(GLOBAL_IMPORTED_ADDRESS_KEY) ||
-    (localStorage.getItem(GLOBAL_ACCOUNT_KEY)
-      ? JSON.parse(localStorage.getItem(GLOBAL_ACCOUNT_KEY)!).accountAddress
-      : null)
-  );
+  const newAddress = localStorage.getItem(GLOBAL_IMPORTED_ADDRESS_KEY);
+  if (newAddress) return newAddress;
+  
+  const legacyData = localStorage.getItem(GLOBAL_ACCOUNT_KEY);
+  if (legacyData) {
+    try {
+      return JSON.parse(legacyData).accountAddress;
+    } catch {
+      return null;
+    }
+  }
+  
+  return null;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function getImportedAccountAddress(): string | null {
if (typeof window === 'undefined') return null;
// Check new key first, then legacy
return (
localStorage.getItem(GLOBAL_IMPORTED_ADDRESS_KEY) ||
(localStorage.getItem(GLOBAL_ACCOUNT_KEY)
? JSON.parse(localStorage.getItem(GLOBAL_ACCOUNT_KEY)!).accountAddress
: null)
);
}
export function getImportedAccountAddress(): string | null {
if (typeof window === 'undefined') return null;
const newAddress = localStorage.getItem(GLOBAL_IMPORTED_ADDRESS_KEY);
if (newAddress) return newAddress;
const legacyData = localStorage.getItem(GLOBAL_ACCOUNT_KEY);
if (legacyData) {
try {
return JSON.parse(legacyData).accountAddress;
} catch {
return null;
}
}
return null;
}
🤖 Prompt for AI Agents
In @src/apps/perps/lib/hyperliquid/keystore.ts around lines 253 - 262,
getImportedAccountAddress currently calls JSON.parse on
localStorage.getItem(GLOBAL_ACCOUNT_KEY)! without error handling which will
throw on corrupted JSON; wrap the parse in a try-catch (or use a safeParse
helper) when LOCAL_ACCOUNT_KEY exists, extract accountAddress from the parsed
object only on successful parse, and return null (and optionally log or remove
the corrupted key) on parse failure so the function never throws. Use the
existing function name getImportedAccountAddress and the keys
GLOBAL_IMPORTED_ADDRESS_KEY and GLOBAL_ACCOUNT_KEY to locate where to add the
try-catch.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In @src/apps/perps/components/TradeForm.tsx:
- Around line 444-523: The variable useBuilderFee is declared twice (const
useBuilderFee = signingAddress === masterAddress; and later const useBuilderFee
= !imported && (signingAddress === masterAddress)), causing the first to be dead
code; remove the earlier definition and ensure the single useBuilderFee
declaration uses the intended logic that includes the imported check
(useBuilderFee = !imported && signingAddress === masterAddress) so all later
references (logs and fee logic) use the correct value.
- Around line 651-696: The current auto-approval branch is unreachable because
useBuilderFee is defined as !imported && (signingAddress === masterAddress), yet
the handler checks imported inside that block; fix by restructuring the
conditions so the builder-fee error is detected first (the existing error check
using error?.message/response), then branch based on whether an imported account
exists: call getImportedAccount() and if imported.accountAddress matches
masterAddress call approveBuilderFeeSDK(imported.privateKey, BUILDER_ADDRESS,
BUILDER_FEE_APPROVAL) with the existing toast flows; otherwise, if
signingAddress === masterAddress and no imported account handle the original
agent/master flow (or show the Settings > Perps Account toast). Ensure you
reference and update the logic around useBuilderFee, getImportedAccount(),
signingAddress, masterAddress and keep the toast.success/error and
approveBuilderFeeSDK calls intact.

In @src/apps/perps/lib/hyperliquid/sdk.ts:
- Around line 177-190: approveAgentSDK is passing the raw masterPrivateKey into
new ExchangeClient, causing inconsistent private key handling compared to
approveBuilderFeeSDK and getExchangeClientForAgent; fix by converting the raw
key to an account with privateKeyToAccount(masterPrivateKey) and pass that
account (not the raw Hex) into ExchangeClient so initialization is consistent
with approveBuilderFeeSDK/getExchangeClientForAgent and avoids type/behavior
mismatches.
🧹 Nitpick comments (2)
src/apps/perps/lib/hyperliquid/keystore.ts (2)

254-263: Add error handling for JSON.parse in getImportedAccountAddress.

Line 260 uses JSON.parse on localStorage data without a try-catch block. If the stored data is corrupted or malformed, this will throw an exception that propagates to callers. Additionally, the code calls localStorage.getItem(GLOBAL_ACCOUNT_KEY) twice, which is inefficient.

♻️ Proposed fix
 export function getImportedAccountAddress(): string | null {
   if (typeof window === 'undefined') return null;
-  // Check new key first, then legacy
-  return (
-    localStorage.getItem(GLOBAL_IMPORTED_ADDRESS_KEY) ||
-    (localStorage.getItem(GLOBAL_ACCOUNT_KEY)
-      ? JSON.parse(localStorage.getItem(GLOBAL_ACCOUNT_KEY)!).accountAddress
-      : null)
-  );
+  
+  // Check new key first
+  const newAddress = localStorage.getItem(GLOBAL_IMPORTED_ADDRESS_KEY);
+  if (newAddress) return newAddress;
+  
+  // Fallback to legacy key
+  const legacyData = localStorage.getItem(GLOBAL_ACCOUNT_KEY);
+  if (legacyData) {
+    try {
+      const parsed = JSON.parse(legacyData);
+      return parsed?.accountAddress || null;
+    } catch (error) {
+      console.warn('[Keystore] Failed to parse legacy imported account data:', error);
+      return null;
+    }
+  }
+  
+  return null;
 }

32-62: Consider privacy implications of storing addresses in localStorage.

Static analysis tools flag lines 45, 130, and 204 for storing addresses in localStorage, which could have privacy implications if users want to maintain anonymity. However, the current implementation is reasonable because:

  1. Private keys ARE properly encrypted using PIN-based encryption (lines 43-44, 202-203)
  2. Wallet addresses are public information on the blockchain
  3. Addresses are necessary for the keystore functionality

The security posture is strong overall with encrypted private keys, session timeouts, and memory cache invalidation. The address storage is a lower-priority concern.

If enhanced privacy is desired, consider:

  • Encrypting addresses alongside private keys
  • Using IndexedDB instead of localStorage for additional isolation
  • Implementing a "privacy mode" that clears all data on session end

Also applies to: 194-219

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1e63d6b and 29afd3b.

📒 Files selected for processing (3)
  • src/apps/perps/components/TradeForm.tsx
  • src/apps/perps/lib/hyperliquid/keystore.ts
  • src/apps/perps/lib/hyperliquid/sdk.ts
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-11-21T13:10:33.422Z
Learnt from: aldin4u
Repo: pillarwallet/x PR: 461
File: src/apps/pulse/components/Search/MarketList.tsx:10-15
Timestamp: 2025-11-21T13:10:33.422Z
Learning: In the Pulse app's MarketList component (src/apps/pulse/components/Search/MarketList.tsx), markets should display liquidity (not price) in the right-hand column. This is per the design specification.

Applied to files:

  • src/apps/perps/components/TradeForm.tsx
  • src/apps/perps/lib/hyperliquid/sdk.ts
📚 Learning: 2025-12-11T12:40:09.964Z
Learnt from: aldin4u
Repo: pillarwallet/x PR: 478
File: src/apps/pulse/components/App/AppWrapper.tsx:22-23
Timestamp: 2025-12-11T12:40:09.964Z
Learning: In the Pulse app's AppWrapper component (src/apps/pulse/components/App/AppWrapper.tsx), when users enter via `/pulse?searching=true` from PillarX home and select a token or market, they should continue into Pulse to complete their transaction, not navigate back to home. Only when closing the search without making a selection should they return to PillarX home.

Applied to files:

  • src/apps/perps/components/TradeForm.tsx
📚 Learning: 2025-08-12T07:42:24.656Z
Learnt from: IAmKio
Repo: pillarwallet/x PR: 351
File: src/apps/pulse/utils/intent.ts:44-53
Timestamp: 2025-08-12T07:42:24.656Z
Learning: In the Pulse app's intent utilities (src/apps/pulse/utils/intent.ts), the team has chosen to use floating-point arithmetic for token amount calculations despite potential precision issues, accepting JavaScript's decimal place limitations as a valid trade-off for their use case.

Applied to files:

  • src/apps/perps/lib/hyperliquid/sdk.ts
🧬 Code graph analysis (1)
src/apps/perps/lib/hyperliquid/keystore.ts (3)
src/apps/perps/lib/hyperliquid/session-manager.ts (3)
  • initializeSessionManager (19-22)
  • resetInactivityTimer (28-41)
  • clearSession (47-58)
src/apps/perps/lib/hyperliquid/signing.ts (1)
  • generateAgentWallet (366-380)
src/apps/perps/lib/encryption.ts (3)
  • encryptWithPin (50-72)
  • EncryptedData (43-47)
  • decryptWithPin (75-97)
🪛 ast-grep (0.40.4)
src/apps/perps/lib/hyperliquid/keystore.ts

[warning] 45-45: Sensitive information detected in localStorage. Storing sensitive data like emails, usernames, or personal information in localStorage exposes it to malicious scripts and XSS attacks. Use secure storage alternatives or avoid storing sensitive data client-side.
Context: localStorage.setItem(getStorageKey(masterAddress, 'address'), address)
Note: [CWE-312] Cleartext Storage of Sensitive Information [REFERENCES]
- https://cwe.mitre.org/data/definitions/312.html
- https://owasp.org/www-community/vulnerabilities/HTML5_Local_Storage_Security

(local-storage-sensitive-information)


[warning] 130-130: Sensitive information detected in localStorage. Storing sensitive data like emails, usernames, or personal information in localStorage exposes it to malicious scripts and XSS attacks. Use secure storage alternatives or avoid storing sensitive data client-side.
Context: localStorage.setItem(getStorageKey(masterAddress, 'address'), address)
Note: [CWE-312] Cleartext Storage of Sensitive Information [REFERENCES]
- https://cwe.mitre.org/data/definitions/312.html
- https://owasp.org/www-community/vulnerabilities/HTML5_Local_Storage_Security

(local-storage-sensitive-information)


[warning] 204-204: Sensitive information detected in localStorage. Storing sensitive data like emails, usernames, or personal information in localStorage exposes it to malicious scripts and XSS attacks. Use secure storage alternatives or avoid storing sensitive data client-side.
Context: localStorage.setItem(GLOBAL_IMPORTED_ADDRESS_KEY, address)
Note: [CWE-312] Cleartext Storage of Sensitive Information [REFERENCES]
- https://cwe.mitre.org/data/definitions/312.html
- https://owasp.org/www-community/vulnerabilities/HTML5_Local_Storage_Security

(local-storage-sensitive-information)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: lint
  • GitHub Check: unit-tests
  • GitHub Check: build
  • GitHub Check: Cloudflare Pages: x

Comment on lines +444 to +523
// 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
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Duplicate useBuilderFee variable definition.

The variable useBuilderFee is defined twice:

  • Line 447: const useBuilderFee = signingAddress === masterAddress;
  • Line 516: const useBuilderFee = !imported && (signingAddress === masterAddress);

The second definition shadows the first, making the first assignment at line 447 dead code. Based on the detailed comments at lines 511-516 and the more comprehensive logic that includes the !imported check, the second definition appears to be the intended one.

🐛 Proposed fix
     }

-    // 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
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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
});
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
});
🤖 Prompt for AI Agents
In @src/apps/perps/components/TradeForm.tsx around lines 444 - 523, The variable
useBuilderFee is declared twice (const useBuilderFee = signingAddress ===
masterAddress; and later const useBuilderFee = !imported && (signingAddress ===
masterAddress)), causing the first to be dead code; remove the earlier
definition and ensure the single useBuilderFee declaration uses the intended
logic that includes the imported check (useBuilderFee = !imported &&
signingAddress === masterAddress) so all later references (logs and fee logic)
use the correct value.

Comment on lines +651 to +696
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;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unreachable auto-approval code for imported accounts.

The builder fee auto-approval logic at lines 659-686 can never execute due to a logical contradiction:

  • Line 652 checks if (useBuilderFee && ...)
  • Line 516 defines useBuilderFee = !imported && (signingAddress === masterAddress)
  • Line 659 checks if (imported && ...)

Since we're inside a block where useBuilderFee is true, the condition !imported must be true, which means imported must be false. Therefore, the check at line 659 if (imported && ...) can never be true.

This means imported accounts will never get auto-approval and will always fall through to the generic error message at line 699.

🔧 Suggested approach

The logic needs to be restructured. Consider checking for the builder fee error first (before checking useBuilderFee), then determining the appropriate action based on whether an imported account is in use:

       // 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()) {
+          // Imported account matches master - can auto-approve
           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;
           } 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.
+          // Using agent or non-matching imported account
           toast.error('Builder Fee Not Approved', {
             id: toastId,
             description: 'Please go to Settings > Perps Account and approve PillarX.',
             duration: 5000
           });
           return;
         }
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
}
if (
(error?.message?.includes('Builder fee has not been approved') ||
error?.response?.data?.includes('Builder fee has not been approved'))
) {
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;
} 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 {
// Using agent or non-matching imported account
toast.error('Builder Fee Not Approved', {
id: toastId,
description: 'Please go to Settings > Perps Account and approve PillarX.',
duration: 5000
});
return;
}
}
🤖 Prompt for AI Agents
In @src/apps/perps/components/TradeForm.tsx around lines 651 - 696, The current
auto-approval branch is unreachable because useBuilderFee is defined as
!imported && (signingAddress === masterAddress), yet the handler checks imported
inside that block; fix by restructuring the conditions so the builder-fee error
is detected first (the existing error check using error?.message/response), then
branch based on whether an imported account exists: call getImportedAccount()
and if imported.accountAddress matches masterAddress call
approveBuilderFeeSDK(imported.privateKey, BUILDER_ADDRESS, BUILDER_FEE_APPROVAL)
with the existing toast flows; otherwise, if signingAddress === masterAddress
and no imported account handle the original agent/master flow (or show the
Settings > Perps Account toast). Ensure you reference and update the logic
around useBuilderFee, getImportedAccount(), signingAddress, masterAddress and
keep the toast.success/error and approveBuilderFeeSDK calls intact.

Comment on lines +177 to +190
export async function approveAgentSDK(
masterPrivateKey: Hex,
agentAddress: string,
agentName?: string
): Promise<any> {
const transport = new HttpTransport();
const client = new ExchangeClient({ wallet: masterPrivateKey, transport });

console.log('[SDK] Approving agent:', agentAddress);
const response = await client.approveAgent({ agentAddress, agentName });
console.log('[SDK] Approve response:', response);

return response;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Inconsistent private key usage in ExchangeClient initialization.

Line 183 passes the raw masterPrivateKey directly to ExchangeClient, while approveBuilderFeeSDK (lines 200-202) and getExchangeClientForAgent (lines 9-11) first convert the private key to an account using privateKeyToAccount. This inconsistency could lead to type errors or unexpected behavior.

Given the commit message mentions "fix sdk private key usage", this appears to be a lingering issue that should be addressed.

🔧 Proposed fix
 export async function approveAgentSDK(
   masterPrivateKey: Hex,
   agentAddress: string,
   agentName?: string
 ): Promise<any> {
+  const account = privateKeyToAccount(masterPrivateKey);
   const transport = new HttpTransport();
-  const client = new ExchangeClient({ wallet: masterPrivateKey, transport });
+  const client = new ExchangeClient({ wallet: account, transport });

   console.log('[SDK] Approving agent:', agentAddress);
   const response = await client.approveAgent({ agentAddress, agentName });
   console.log('[SDK] Approve response:', response);

   return response;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function approveAgentSDK(
masterPrivateKey: Hex,
agentAddress: string,
agentName?: string
): Promise<any> {
const transport = new HttpTransport();
const client = new ExchangeClient({ wallet: masterPrivateKey, transport });
console.log('[SDK] Approving agent:', agentAddress);
const response = await client.approveAgent({ agentAddress, agentName });
console.log('[SDK] Approve response:', response);
return response;
}
export async function approveAgentSDK(
masterPrivateKey: Hex,
agentAddress: string,
agentName?: string
): Promise<any> {
const account = privateKeyToAccount(masterPrivateKey);
const transport = new HttpTransport();
const client = new ExchangeClient({ wallet: account, transport });
console.log('[SDK] Approving agent:', agentAddress);
const response = await client.approveAgent({ agentAddress, agentName });
console.log('[SDK] Approve response:', response);
return response;
}
🤖 Prompt for AI Agents
In @src/apps/perps/lib/hyperliquid/sdk.ts around lines 177 - 190,
approveAgentSDK is passing the raw masterPrivateKey into new ExchangeClient,
causing inconsistent private key handling compared to approveBuilderFeeSDK and
getExchangeClientForAgent; fix by converting the raw key to an account with
privateKeyToAccount(masterPrivateKey) and pass that account (not the raw Hex)
into ExchangeClient so initialization is consistent with
approveBuilderFeeSDK/getExchangeClientForAgent and avoids type/behavior
mismatches.

@github-actions github-actions bot requested a deployment to Preview (perps) January 12, 2026 16:46 Abandoned
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In @Builders codes/fetch_builder_data.js:
- Around line 13-36: The Promise can hang because errors emitted by the request
returned from https.get are not handled; capture the request object returned by
https.get(...) (the value currently not stored) and attach a 'error' listener on
it that resolves null (and optionally logs the error) so DNS/connection-level
failures don't leave the promise unresolved; keep the existing response error
handling on the 'res' object and ensure you only resolve once in either the
response end/error handlers or the request 'error' handler.

In @Builders codes/package.json:
- Line 5: The package.json "main" entry currently points to index.js but the
actual script added in this PR is fetch_builder_data.js; update the "main" field
to "fetch_builder_data.js" (or alternatively rename the script to index.js) so
the package's main entry matches the actual module name referenced by the code
(adjust the "main" field in package.json accordingly).

In @Builders codes/verify_referrals.js:
- Around line 22-26: The JSON.parse call inside the res.on('end') callback can
throw on malformed responses; wrap the parse and subsequent formatOutput(body)
usage in a try-catch inside the res.on('end', ...) handler, catch the error, log
a useful message including the raw body and the parse error (using console.error
or the existing logger), and avoid calling formatOutput when parsing fails
(return/exit the handler after logging) so the process doesn't crash from an
uncaught exception.

In @src/apps/perps/components/PositionsCard.tsx:
- Around line 964-983: The conditional contains dead inner else branches because
hasOrders is defined as (tps.length > 0 || sls.length > 0), so the check
tps.length === 0 && sls.length === 0 is redundant; simplify both mobile and
desktop blocks in PositionsCard by replacing the nested ternary with a single
conditional: if no orders render "-" else render the TP and SL spans when
tps.length > 0 and sls.length > 0 respectively (using existing tps, sls and
formatPrice), and remove the unreachable else branches.
- Around line 507-544: The unlock handler calls async functions
handleExecuteClose and handleCancelOrder without awaiting them, causing
setPendingAction(null) and return true to run before those actions complete;
update handleUnlock to await handleExecuteClose() and await
handleCancelOrder(pendingAction.data) where invoked, and only clear
pendingAction (setPendingAction(null)) and return true after those awaits
complete (preserve existing try/catch behavior and apply to both the imported
account and agent wallet branches).
- Line 89: The component declares state with useState<UserState | null>
(internalUserState / setInternalUserState) but UserState isn't imported, causing
a TypeScript error; fix by adding UserState to the existing import list from
../lib/hyperliquid/types (i.e., import { UserState } from
'../lib/hyperliquid/types' along with any other imported symbols) so the type
reference resolves.
🧹 Nitpick comments (13)
Builders codes/verify_referrals.js (2)

3-6: Consider parameterizing the hardcoded user address.

The Ethereum address is hardcoded, limiting script reusability. Additionally, the filename verify_referrals.js doesn't align with the userFunding request type.

♻️ Suggested improvement
+const user = process.argv[2] || "0x5e1D081488a5e746c1a13Bf92103C1B9eE5962A2";
+
+if (!user) {
+    console.error('Usage: node verify_referrals.js <user_address>');
+    process.exit(1);
+}
+
 const data = JSON.stringify({
     type: "userFunding",
-    user: "0x5e1D081488a5e746c1a13Bf92103C1B9eE5962A2"
+    user: user
 });

19-27: Add HTTP status code validation before processing the response.

The script processes the response body regardless of HTTP status. On API errors (4xx/5xx), this could lead to confusing output or parse failures.

♻️ Suggested improvement
 const req = https.request(options, (res) => {
     let body = '';
     res.on('data', (chunk) => body += chunk);
     res.on('end', () => {
+        if (res.statusCode < 200 || res.statusCode >= 300) {
+            console.error(`Request failed with status ${res.statusCode}`);
+            console.error('Response:', body);
+            return;
+        }
         try {
             const parsed = JSON.parse(body);
             console.log(formatOutput(parsed));
         } catch (e) {
             console.error('Failed to parse response:', e.message);
             console.error('Raw response:', body);
         }
     });
 });
Builders codes/fetch_builder_data.js (5)

1-3: Remove unused fs import.

The fs module is imported but never used in this script.

Proposed fix
 const https = require('https');
-const fs = require('fs');
 const lz4js = require('lz4js');

39-52: Naive CSV parser may fail on quoted fields.

The parser splits on commas without handling quoted fields. If any CSV field contains commas (e.g., in user addresses or notes), parsing will break. Consider using a CSV parsing library like csv-parse for robustness, or document that the input format is guaranteed simple.


99-111: Avoid parsing CSV twice; combine with first pass.

The CSV is parsed once in lines 73-93 for daily stats, then again here for user breakdown. This redundant parsing can be eliminated by building userBreakdown during the first iteration.

Proposed fix — compute userBreakdown in the first pass
     let totalFees = 0;
     const uniqueUsers = new Set();
     const dailyStats = {};
+    const userBreakdown = {};

     results.forEach(({ date, csv }) => {
         const records = parseCSV(csv);
         let dailyFees = 0;
         let dailyUsers = new Set();

         records.forEach(r => {
             const fee = parseFloat(r.builder_fee || 0);
             dailyFees += fee;
             totalFees += fee;
             if (r.user) {
                 uniqueUsers.add(r.user);
                 dailyUsers.add(r.user);
+                userBreakdown[r.user] = (userBreakdown[r.user] || 0) + fee;
             }
         });

         dailyStats[date] = {
             fees: dailyFees,
             users: dailyUsers.size,
             transactions: records.length
         };
     });

     console.table(dailyStats);
     console.log(`\nTotal Builder Fees: ${totalFees.toFixed(6)} USDC`);
     console.log(`Total Unique Users: ${uniqueUsers.size}`);

     console.log("\nUser Breakdown:");
-    const userBreakdown = {};
-    results.forEach(({ csv }) => {
-        const records = parseCSV(csv);
-        records.forEach(r => {
-            if (r.user) {
-                if (!userBreakdown[r.user]) {
-                    userBreakdown[r.user] = 0;
-                }
-                userBreakdown[r.user] += parseFloat(r.builder_fee || 0);
-            }
-        });
-    });
-
     Object.entries(userBreakdown).forEach(([user, fee]) => {

54-61: Consider parallel fetching for performance.

The dates are fetched sequentially with await in a loop. Since these requests are independent, using Promise.all would improve performance.

Proposed parallel fetch
 async function main() {
-    const results = [];
-    for (const date of dates) {
-        const result = await fetchAndProcess(date);
-        if (result) {
-            results.push(result);
-        }
-    }
+    const fetched = await Promise.all(dates.map(date => fetchAndProcess(date)));
+    const results = fetched.filter(Boolean);

118-118: Add error handling for the main() invocation.

If main() throws an unhandled rejection, the process may exit silently or with a cryptic error. Adding .catch() ensures errors are surfaced.

Proposed fix
-main();
+main().catch(err => {
+    console.error('Fatal error:', err);
+    process.exit(1);
+});
Builders codes/package.json (1)

12-14: Consider pinning lz4js to exact version 0.2.0 for reproducible builds.

The current caret range ^0.2.0 effectively resolves to version 0.2.0 only (the latest available), but explicitly pinning the version would clarify intent for reproducible builds. Change to "lz4js": "0.2.0" to ensure builds are fully deterministic.

src/apps/perps/components/PositionsCard.tsx (5)

49-55: Remove unused imports.

getMarkPrice, getOpenOrders, and getUserFills are imported but never used in this component. Only getFrontendOpenOrders and getMetaAndAssetCtxs are actually called.

🧹 Proposed cleanup
 import {
-  getMarkPrice,
-  getOpenOrders,
   getFrontendOpenOrders,
   getMetaAndAssetCtxs,
-  getUserFills,
 } from '../lib/hyperliquid/client';

281-353: Close position handler has solid wallet resolution and error handling.

The priority flow (imported account → agent wallet → unlock modal) is correctly implemented. A few observations:

  1. Line 325: Hardcoded 6 decimal places. Consider using the asset's szDecimals from universe metadata for precision:
-      const sizeStr = sizeToClose.toFixed(6);
+      const decimals = universe[coinIndex]?.szDecimals ?? 6;
+      const sizeStr = sizeToClose.toFixed(decimals);
  1. Line 294: getAgentWallet appears synchronous based on the relevant snippets, so await is unnecessary (though harmless).

632-641: Loading skeleton shown during refresh may cause UI flicker.

When positions.length > 0 and isLoading is true (during a refresh), this shows skeleton placeholders instead of the existing positions. This creates a jarring UX where data disappears during refresh.

Consider showing existing positions with a subtle loading indicator instead:

♻️ Proposed approach
-              {positions.length === 0 ? (
+              {isLoading && positions.length === 0 ? (
+                <div className="space-y-3">
+                  {[1, 2, 3].map((i) => (
+                    <Skeleton key={i} className="h-24 w-full rounded-lg" />
+                  ))}
+                </div>
+              ) : positions.length === 0 ? (
                 <p className="text-sm text-muted-foreground text-center py-4 bg-muted/20 rounded-lg border border-dashed">
                   No open positions
                 </p>
-              ) : isLoading ? (
-                <div className="space-y-3">
-                  {[1, 2, 3].map((i) => (
-                    <Skeleton key={i} className="h-24 w-full rounded-lg" />
-                  ))}
-                </div>
               ) : expandedPositionIndex !== null &&

819-842: Use unique identifier for list keys instead of array index.

Using array index as the React key can cause reconciliation issues if positions reorder. Since position.coin is guaranteed unique (per the deduplication logic at lines 166-170), use it as the key.

♻️ Proposed fix
-                  {positions.map((position, index) => {
+                  {positions.map((position) => {
                     // ... existing code ...
+                    const positionKey = position.coin;
 
                     if (isMobile) {
                       return (
                         <div
-                          key={index}
+                          key={positionKey}
                           onClick={() => {
                             handlePositionClick(position.coin);
-                            setExpandedPositionIndex(index);
+                            setExpandedPositionIndex(positions.findIndex(p => p.coin === position.coin));
                           }}

Note: If you want to keep using index for setExpandedPositionIndex, you can keep the index parameter but use position.coin for the key.

Also applies to: 991-998


831-831: Unused variable leverage.

The variable leverage is calculated but never used in the desktop position row rendering. Line 1012 calls calculateLeverage(position) instead.

🧹 Remove unused variable
                     const positionValue =
                       Math.abs(parseFloat(position.szi)) *
                       parseFloat(position.markPx || '0');
                     const marginUsed = parseFloat(position.marginUsed || '0');
-                    const leverage = position.leverage?.value || 0;
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 29afd3b and adef24d.

⛔ Files ignored due to path filters (12)
  • Builders codes/node_modules/.package-lock.json is excluded by !**/node_modules/**
  • Builders codes/node_modules/lz4js/.eslintrc is excluded by !**/node_modules/**
  • Builders codes/node_modules/lz4js/.npmignore is excluded by !**/node_modules/**
  • Builders codes/node_modules/lz4js/.travis.yml is excluded by !**/node_modules/**
  • Builders codes/node_modules/lz4js/README.md is excluded by !**/node_modules/**
  • Builders codes/node_modules/lz4js/lz4.js is excluded by !**/node_modules/**
  • Builders codes/node_modules/lz4js/package.json is excluded by !**/node_modules/**
  • Builders codes/node_modules/lz4js/test/cases/lz4.js is excluded by !**/node_modules/**
  • Builders codes/node_modules/lz4js/test/cases/xxh32.js is excluded by !**/node_modules/**
  • Builders codes/node_modules/lz4js/util.js is excluded by !**/node_modules/**
  • Builders codes/node_modules/lz4js/xxh32.js is excluded by !**/node_modules/**
  • Builders codes/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (4)
  • Builders codes/fetch_builder_data.js
  • Builders codes/package.json
  • Builders codes/verify_referrals.js
  • src/apps/perps/components/PositionsCard.tsx
🧰 Additional context used
🧠 Learnings (7)
📚 Learning: 2025-04-23T15:04:20.826Z
Learnt from: RanaBug
Repo: pillarwallet/x PR: 290
File: src/apps/pillarx-app/components/TileTitle/TitleTitle.tsx:6-10
Timestamp: 2025-04-23T15:04:20.826Z
Learning: In this repository, TileTitleProps and TileTitle are different types that serve different purposes. TileTitleProps is used for the TileTitle component and has optional fields (title?, leftDecorator?, rightDecorator?), while TileTitle in api.ts has a required text field. The TileTitleProps interface aligns with the TokensMarketData.title type in api.ts which also has optional fields.

Applied to files:

  • src/apps/perps/components/PositionsCard.tsx
📚 Learning: 2025-11-21T13:10:33.422Z
Learnt from: aldin4u
Repo: pillarwallet/x PR: 461
File: src/apps/pulse/components/Search/MarketList.tsx:10-15
Timestamp: 2025-11-21T13:10:33.422Z
Learning: In the Pulse app's MarketList component (src/apps/pulse/components/Search/MarketList.tsx), markets should display liquidity (not price) in the right-hand column. This is per the design specification.

Applied to files:

  • src/apps/perps/components/PositionsCard.tsx
📚 Learning: 2025-08-20T14:01:31.715Z
Learnt from: IAmKio
Repo: pillarwallet/x PR: 387
File: src/apps/leaderboard/components/LeaderboardTab/LeaderboardTab.tsx:118-125
Timestamp: 2025-08-20T14:01:31.715Z
Learning: In src/apps/leaderboard/components/LeaderboardTab/LeaderboardTab.tsx, the "My rank" calculation uses 'trading' context while the list uses 'migration' context when calling calculateFinalPxPoints. This is intentional design to control where the 200-point bonus is applied - the 'trading' context with 'all' timeTab bypasses the bonus while 'migration' context allows it, preventing points from being added in the wrong place.

Applied to files:

  • src/apps/perps/components/PositionsCard.tsx
📚 Learning: 2026-01-07T12:27:46.789Z
Learnt from: vignesha22
Repo: pillarwallet/x PR: 486
File: src/apps/pulse/components/Onboarding/TopUpScreen.tsx:1005-1049
Timestamp: 2026-01-07T12:27:46.789Z
Learning: In the Pulse app TopUpScreen gasless flow (src/apps/pulse/components/Onboarding/TopUpScreen.tsx), when the Gasless Fee Estimate UI is displayed, the fee token (selectedFeeAsset) is always the same as the topup token (selectedToken) because selectedFeeAsset is only set when a matching paymaster is found where gasToken matches selectedToken.address (lines 377-391).

Applied to files:

  • src/apps/perps/components/PositionsCard.tsx
📚 Learning: 2025-03-28T09:22:22.712Z
Learnt from: RanaBug
Repo: pillarwallet/x PR: 275
File: src/apps/the-exchange/components/DropdownTokensList/DropdownTokenList.tsx:180-195
Timestamp: 2025-03-28T09:22:22.712Z
Learning: In the Exchange app, `swapTokenList` and `receiveTokenList` are derived from `searchTokenResult` when search is active, so including `searchToken` in the useEffect dependency array that uses these lists would be redundant as the lists will update when search results change.

Applied to files:

  • src/apps/perps/components/PositionsCard.tsx
📚 Learning: 2025-11-04T14:34:00.373Z
Learnt from: RanaBug
Repo: pillarwallet/x PR: 445
File: src/containers/Authorized.tsx:63-77
Timestamp: 2025-11-04T14:34:00.373Z
Learning: In src/containers/Authorized.tsx, the tempKit useEffect intentionally excludes chainId from its dependency array because the kit is used by useWalletModeVerification to check wallet mode across all supported chains, regardless of the currently selected chainId. The chainId parameter is only used for initial kit configuration and does not affect the multi-chain verification results.

Applied to files:

  • src/apps/perps/components/PositionsCard.tsx
📚 Learning: 2025-08-12T07:42:24.656Z
Learnt from: IAmKio
Repo: pillarwallet/x PR: 351
File: src/apps/pulse/utils/intent.ts:44-53
Timestamp: 2025-08-12T07:42:24.656Z
Learning: In the Pulse app's intent utilities (src/apps/pulse/utils/intent.ts), the team has chosen to use floating-point arithmetic for token amount calculations despite potential precision issues, accepting JavaScript's decimal place limitations as a valid trade-off for their use case.

Applied to files:

  • src/apps/perps/components/PositionsCard.tsx
🧬 Code graph analysis (3)
Builders codes/verify_referrals.js (1)
Builders codes/fetch_builder_data.js (2)
  • https (1-1)
  • data (42-42)
Builders codes/fetch_builder_data.js (1)
Builders codes/verify_referrals.js (2)
  • https (1-1)
  • data (3-6)
src/apps/perps/components/PositionsCard.tsx (5)
src/apps/perps/lib/hyperliquid/types.ts (3)
  • HyperliquidPosition (88-103)
  • HyperliquidOrder (106-125)
  • UniverseAsset (128-133)
src/apps/perps/hooks/use-mobile.tsx (1)
  • useIsMobile (5-21)
src/apps/perps/lib/hyperliquid/keystore.ts (6)
  • getImportedAccount (274-309)
  • getAgentWallet (358-384)
  • isImportedAccountEncrypted (249-252)
  • isAgentWalletEncrypted (110-113)
  • unlockImportedAccount (221-247)
  • unlockAgentWallet (64-108)
src/apps/perps/lib/hyperliquid/sdk.ts (2)
  • placeMarketOrderAgent (17-57)
  • cancelOrderAgent (149-172)
src/apps/perps/components/UnlockWalletModal.tsx (1)
  • UnlockWalletModal (25-133)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: lint
  • GitHub Check: unit-tests
  • GitHub Check: build
  • GitHub Check: Cloudflare Pages: x
🔇 Additional comments (4)
Builders codes/verify_referrals.js (1)

29-31: LGTM!

The formatOutput helper is clean and straightforward.

src/apps/perps/components/PositionsCard.tsx (3)

103-218: Data fetching logic is well-structured with appropriate fallbacks.

The fetchData callback properly handles multiple data sources (external props vs internal fetch), builds a price map, and enriches positions with mark prices and universe metadata. The fallback logic for missing universe metadata (lines 184-193) is a good defensive measure.

One minor observation: internalUserState is both a dependency and set within the callback. This is safe here since it's only read at line 151 as a fallback when userState prop is not provided, but consider if this could cause unnecessary re-fetches.


546-598: Dialog and modal setup looks good.

The UnlockWalletModal and close position Dialog are properly integrated with appropriate state management. The slider for close percentage with preset buttons (25%, 50%, 75%, 100%) provides good UX.


438-504: TP/SL aggregation helper is well-implemented.

The getOpenTP_SL function correctly:

  • Filters reduce-only orders for the position
  • Determines order direction based on position side
  • Classifies orders as TP or SL using orderType with price-based fallback
  • Returns sorted arrays

The fallback classification logic (lines 478-496) handles edge cases where orderType is not available.

Comment on lines +13 to +36
return new Promise((resolve) => {
https.get(url, (res) => {
if (res.statusCode !== 200) {
// console.log(`No data for ${date} (Status: ${res.statusCode})`);
resolve(null);
return;
}

const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => {
const buffer = Buffer.concat(chunks);
try {
const decompressed = lz4js.decompress(buffer);
const csv = Buffer.from(decompressed).toString('utf8');
resolve({ date, csv });
} catch (e) {
console.error(`Error decompressing ${date}:`, e.message);
resolve(null);
}
});
res.on('error', () => resolve(null));
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing error handler on the request object.

The https.get call returns a request object that can emit errors (e.g., DNS failures, connection refused). Currently only response-level errors are handled, but request-level errors would leave the promise hanging.

Proposed fix
     return new Promise((resolve) => {
-        https.get(url, (res) => {
+        const req = https.get(url, (res) => {
             if (res.statusCode !== 200) {
                 // console.log(`No data for ${date} (Status: ${res.statusCode})`);
                 resolve(null);
                 return;
             }

             const chunks = [];
             res.on('data', chunk => chunks.push(chunk));
             res.on('end', () => {
                 const buffer = Buffer.concat(chunks);
                 try {
                     const decompressed = lz4js.decompress(buffer);
                     const csv = Buffer.from(decompressed).toString('utf8');
                     resolve({ date, csv });
                 } catch (e) {
                     console.error(`Error decompressing ${date}:`, e.message);
                     resolve(null);
                 }
             });
             res.on('error', () => resolve(null));
         });
+        req.on('error', () => resolve(null));
     });
🤖 Prompt for AI Agents
In @Builders codes/fetch_builder_data.js around lines 13 - 36, The Promise can
hang because errors emitted by the request returned from https.get are not
handled; capture the request object returned by https.get(...) (the value
currently not stored) and attach a 'error' listener on it that resolves null
(and optionally logs the error) so DNS/connection-level failures don't leave the
promise unresolved; keep the existing response error handling on the 'res'
object and ensure you only resolve once in either the response end/error
handlers or the request 'error' handler.

"name": "builder-codes-fetcher",
"version": "1.0.0",
"description": "Fetch builder codes",
"main": "index.js",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Main entry doesn't match actual script filename.

The main entry is set to index.js, but the actual script in this PR is fetch_builder_data.js. Update to match:

Proposed fix
-  "main": "index.js",
+  "main": "fetch_builder_data.js",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"main": "index.js",
"main": "fetch_builder_data.js",
🤖 Prompt for AI Agents
In @Builders codes/package.json at line 5, The package.json "main" entry
currently points to index.js but the actual script added in this PR is
fetch_builder_data.js; update the "main" field to "fetch_builder_data.js" (or
alternatively rename the script to index.js) so the package's main entry matches
the actual module name referenced by the code (adjust the "main" field in
package.json accordingly).

Comment on lines +22 to +26
res.on('end', () => {
// console.log(body);
const parsed = JSON.parse(body);
console.log(formatOutput(parsed));
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Wrap JSON.parse in a try-catch to prevent uncaught exceptions.

If the API returns malformed JSON or an unexpected response, the script will crash with an unhandled exception. The request error handler (line 33) won't catch JSON parse errors.

🐛 Proposed fix
     res.on('end', () => {
-        // console.log(body);
-        const parsed = JSON.parse(body);
-        console.log(formatOutput(parsed));
+        try {
+            const parsed = JSON.parse(body);
+            console.log(formatOutput(parsed));
+        } catch (e) {
+            console.error('Failed to parse response:', e.message);
+            console.error('Raw response:', body);
+        }
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
res.on('end', () => {
// console.log(body);
const parsed = JSON.parse(body);
console.log(formatOutput(parsed));
});
res.on('end', () => {
try {
const parsed = JSON.parse(body);
console.log(formatOutput(parsed));
} catch (e) {
console.error('Failed to parse response:', e.message);
console.error('Raw response:', body);
}
});
🤖 Prompt for AI Agents
In @Builders codes/verify_referrals.js around lines 22 - 26, The JSON.parse call
inside the res.on('end') callback can throw on malformed responses; wrap the
parse and subsequent formatOutput(body) usage in a try-catch inside the
res.on('end', ...) handler, catch the error, log a useful message including the
raw body and the parse error (using console.error or the existing logger), and
avoid calling formatOutput when parsing fails (return/exit the handler after
logging) so the process doesn't crash from an uncaught exception.

const [closeDialogOpen, setCloseDialogOpen] = useState(false);
const [universe, setUniverse] = useState<UniverseAsset[]>([]);
const [openOrders, setOpenOrders] = useState<HyperliquidOrder[]>([]);
const [internalUserState, setInternalUserState] = useState<UserState | null>(null);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and view the imports and line 89
cat -n src/apps/perps/components/PositionsCard.tsx | head -120

Repository: pillarwallet/x

Length of output: 4748


🏁 Script executed:

# Check if UserState is exported from the types file
rg -n "export.*UserState" --type=ts

Repository: pillarwallet/x

Length of output: 254


🏁 Script executed:

# Search for all imports in PositionsCard.tsx
rg -n "^import" src/apps/perps/components/PositionsCard.tsx

Repository: pillarwallet/x

Length of output: 845


UserState type is used but not imported, causing a TypeScript compilation error.

At line 89, useState<UserState | null>(null) references UserState, but the type is not imported. The type is exported from ../lib/hyperliquid/types and should be added to the existing import statement.

🐛 Proposed fix
 import type {
   HyperliquidPosition,
   HyperliquidOrder,
   UniverseAsset,
+  UserState,
 } from '../lib/hyperliquid/types';
🤖 Prompt for AI Agents
In @src/apps/perps/components/PositionsCard.tsx at line 89, The component
declares state with useState<UserState | null> (internalUserState /
setInternalUserState) but UserState isn't imported, causing a TypeScript error;
fix by adding UserState to the existing import list from
../lib/hyperliquid/types (i.e., import { UserState } from
'../lib/hyperliquid/types' along with any other imported symbols) so the type
reference resolves.

Comment on lines +507 to +544
const handleUnlock = async (pin: string): Promise<boolean> => {
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;
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing await on async function calls may cause race conditions.

handleExecuteClose and handleCancelOrder are async functions, but they're called without await. This means setPendingAction(null) and return true execute before the action completes, potentially causing UI state inconsistencies.

🔧 Proposed fix
   const handleUnlock = async (pin: string): Promise<boolean> => {
     try {
       // Try unlocking imported account first
       if (isImportedAccountEncrypted()) {
         const unlocked = await unlockImportedAccount(pin);
         if (unlocked) {
           setShowUnlockModal(false);
+          setPendingAction(null);
           // Retry pending action
           if (pendingAction?.type === 'close') {
-            handleExecuteClose();
+            await handleExecuteClose();
           } else if (pendingAction?.type === 'cancel' && pendingAction.data) {
-            handleCancelOrder(pendingAction.data);
+            await handleCancelOrder(pendingAction.data);
           }
-          setPendingAction(null);
           return true;
         }
       }
 
       // Try unlocking agent wallet
       if (isAgentWalletEncrypted(masterAddress)) {
         const unlocked = await unlockAgentWallet(masterAddress, pin);
         if (unlocked) {
           setShowUnlockModal(false);
+          setPendingAction(null);
           // Retry pending action
           if (pendingAction?.type === 'close') {
-            handleExecuteClose();
+            await handleExecuteClose();
           } else if (pendingAction?.type === 'cancel' && pendingAction.data) {
-            handleCancelOrder(pendingAction.data);
+            await handleCancelOrder(pendingAction.data);
           }
-          setPendingAction(null);
           return true;
         }
       }
🤖 Prompt for AI Agents
In @src/apps/perps/components/PositionsCard.tsx around lines 507 - 544, The
unlock handler calls async functions handleExecuteClose and handleCancelOrder
without awaiting them, causing setPendingAction(null) and return true to run
before those actions complete; update handleUnlock to await handleExecuteClose()
and await handleCancelOrder(pendingAction.data) where invoked, and only clear
pendingAction (setPendingAction(null)) and return true after those awaits
complete (preserve existing try/catch behavior and apply to both the imported
account and agent wallet branches).

Comment on lines +964 to +983
) : (
<span className="flex gap-1 justify-end">
{tps.length === 0 && sls.length === 0 ? (
<span>-</span>
) : (
<>
{tps.length > 0 && (
<span className="text-green-500">
${formatPrice(tps[0].price)}
</span>
)}
{sls.length > 0 && (
<span className="text-red-500">
${formatPrice(sls[0].price)}
</span>
)}
</>
)}
</span>
)}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Dead code in TP/SL fallback branches.

The else branches at lines 968-981 and 1068-1081 are unreachable. Since hasOrders = tps.length > 0 || sls.length > 0, when hasOrders is false, tps.length === 0 && sls.length === 0 is always true, making the inner else branch dead code.

🧹 Simplified logic for mobile (lines 964-984)
                                 )}
                               </span>
                             ) : (
-                              <span className="flex gap-1 justify-end">
-                                {tps.length === 0 && sls.length === 0 ? (
-                                  <span>-</span>
-                                ) : (
-                                  <>
-                                    {tps.length > 0 && (
-                                      <span className="text-green-500">
-                                        ${formatPrice(tps[0].price)}
-                                      </span>
-                                    )}
-                                    {sls.length > 0 && (
-                                      <span className="text-red-500">
-                                        ${formatPrice(sls[0].price)}
-                                      </span>
-                                    )}
-                                  </>
-                                )}
-                              </span>
+                              <span>-</span>
                             )}
🧹 Simplified logic for desktop (lines 1064-1083)
                           ) : (
-                            <div className="flex flex-col items-end text-xs leading-tight">
-                              {tps.length === 0 && sls.length === 0 ? (
-                                <span className="text-muted-foreground">-</span>
-                              ) : (
-                                <>
-                                  {tps.length > 0 && (
-                                    <span className="text-green-500">
-                                      {formatPrice(tps[0].price)}
-                                    </span>
-                                  )}
-                                  {sls.length > 0 && (
-                                    <span className="text-red-500">
-                                      {formatPrice(sls[0].price)}
-                                    </span>
-                                  )}
-                                </>
-                              )}
-                            </div>
+                            <span className="text-muted-foreground">-</span>
                           )}

Also applies to: 1064-1083

🤖 Prompt for AI Agents
In @src/apps/perps/components/PositionsCard.tsx around lines 964 - 983, The
conditional contains dead inner else branches because hasOrders is defined as
(tps.length > 0 || sls.length > 0), so the check tps.length === 0 && sls.length
=== 0 is redundant; simplify both mobile and desktop blocks in PositionsCard by
replacing the nested ternary with a single conditional: if no orders render "-"
else render the TP and SL spans when tps.length > 0 and sls.length > 0
respectively (using existing tps, sls and formatPrice), and remove the
unreachable else branches.

@github-actions github-actions bot requested a deployment to Preview (perps) January 15, 2026 09:54 Abandoned
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @.npmrc:
- Around line 6-7: The comment above the git-tag-version=false setting is
misleading; change it to accurately state its effect (it disables automatic git
commit/tag creation during npm version bumps). Update the comment that precedes
git-tag-version=false to something like "Disable automatic git commit and tag
creation during npm version bumps" so it correctly documents the purpose of the
git-tag-version=false config.
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between adef24d and 128f99a.

📒 Files selected for processing (1)
  • .npmrc
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: unit-tests
  • GitHub Check: lint
  • GitHub Check: build
  • GitHub Check: Cloudflare Pages: x
🔇 Additional comments (1)
.npmrc (1)

1-4: Clarify the purpose of these settings—they don't disable git operations.

These settings don't "disable git operations during npm install." The project uses patch-package with a postinstall hook that requires git to track and apply patches. The actual issue was that patch-package was failing in the Cloudflare Pages CI environment due to git permission errors.

The fix works like this:

  • unsafe-perm=true allows the postinstall script to run with elevated privileges, which resolves git permission issues in containerized CI environments. This is the key setting that fixes the stated problem.
  • ignore-scripts=false is npm's default behavior and is redundant unless overriding a parent configuration.
  • git-tag-version=false only affects the npm version command and doesn't prevent git operations during install.

Security consideration: unsafe-perm=true allows lifecycle scripts from dependencies to run with elevated privileges. While necessary for this fix, ensure you understand the risk—malicious postinstall scripts could execute with elevated permissions. This is a standard tradeoff in CI environments, but warrants awareness.

Likely an incorrect or invalid review comment.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines +6 to +7
# Configure git to use system config only
git-tag-version=false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

git-tag-version npm config setting documentation

💡 Result:

Summary: The npm config key git-tag-version (boolean, default: true) controls whether npm version creates a git commit and tag. Set it per-project in .npmrc or globally with npm config set.

Examples:

  • Disable globally: npm config set git-tag-version false. [1][2]
  • Disable per-repo: add git-tag-version=false to the repository's .npmrc. [1][2]

References:

  • npm docs (npm version / config: git-tag-version). [1]
  • Example / discussion (Stack Overflow). [2]

🌐 Web query:

npm install git config errors CI/CD environment solutions

💡 Result:

Here are concise, practical fixes for common "npm install" + git/config errors in CI/CD, with quick actionable commands and references.

  1. Authentication to private Git repos or registries
  • Use CI secrets + project .npmrc: add //registry.npmjs.org/:_authToken=${NPM_TOKEN} and set NPM_TOKEN in CI secrets. [1]
  • For Git-hosted deps, prefer git+https with a token or use SSH keys (deploy key) + ssh-agent in the runner. Example: "pkg": "git+https://<user>:<token>@gitlab.com/group/repo.git#tag" or "git+ssh://[email protected]/group/repo.git#tag". [2][3]
  1. Ensure git auth scheme used by CI (ssh vs https)
  • Rewrite URLs in CI to match available auth: set git config replacement so SSH URLs use HTTPS with CI token:
    git config --global url."https://gitlab-ci-token:[TOKEN]@gitlab.com/".insteadOf "ssh://[email protected]/"
    This lets npm fetch private git deps in pipelines. [4][5]
  1. SSH keys/agent in CI
  • Install private key as secret, start ssh-agent, add key, and ensure known_hosts contains host (or use StrictHostKeyChecking=no carefully). Many CI images need ssh-agent setup before npm install. [3]
  1. npm registry 401 / token scope & expiry
  • Use granular tokens with correct scopes for private registries (read:packages or appropriate scopes). Store token as secret and use project .npmrc with ${ENV_VAR}. Rotate tokens per provider rules. [1][6]
  1. git config user/email & permission scope errors
  • If build steps run as root (sudo), git config may be different. Set required git config in the pipeline before npm install:
    git config --global user.name "CI Bot"
    git config --global user.email "[email protected]"
    Also check git config --global url."https://github.com/".insteadof "ssh://git@github.com/" when needed. [5]
  1. npm client / environment issues
  • Avoid running npm install with sudo; ensure correct user has access to SSH keys and .gitconfig.
  • Upgrade npm if you hit known bugs (some git install bugs fixed in newer npm versions). [5]
  1. Debugging tips (quick)
  • Print effective .npmrc and git config in CI job (mask tokens) to verify injected values.
  • Try cloning the dependency repo manually inside the CI job to isolate git vs npm issues.
  • Check CI provider docs for built‑in token variables (e.g., CI_JOB_TOKEN) and any known registry bugs. [1][6]

References

  • Use private packages in CI/CD (npm docs). [1]
  • Installing private GitHub/GitLab deps (Stack Overflow / advice for git+https & tokens). [2][7]
  • GitLab CI: ssh vs https auth for git dependencies (GitLab issue / examples using git config rewrite). [3][4]
  • Stack Overflow: git config insteadOf and npm/git install fixes. [5]
  • CircleCI / provider notes on 401 and token policies (token scope/rotation). [6]

If you want, tell me: (a) your CI provider (GitHub Actions / GitLab / CircleCI / Jenkins / other), (b) whether you prefer SSH or token-based HTTPS, and I’ll give you an exact pipeline snippet to fix it.


🌐 Web query:

npm config settings disable git operations during install

💡 Result:

Short answer — two practical ways:

  1. Force offline (no network/git calls):
  • npm install --offline
  • or npm config set offline true
    (uses npm’s offline mode which prevents network requests). [1]
  1. Prevent npm from invoking the git binary:
  • npm config set git /bin/false (on Windows set to a nonexistent path or a harmless executable)
    This makes npm’s configured git command fail, so git-based installs will not run. Note: packages that specify git URLs will then fail to install. [2]

Sources:
[1] npm config docs — offline/prefer-offline.
[2] npm config docs — git (command to use for git operations).


Update the .npmrc comment to accurately reflect git-tag-version=false.

The comment "Configure git to use system config only" is misleading. The git-tag-version=false setting only prevents npm from creating a git commit and tag during the npm version command. It does not affect npm install operations or git configuration usage.

If this was added to address git config errors during npm install (as suggested in the PR objectives), this setting won't help. Update the comment to clarify the actual purpose: "Disable automatic git tag creation during version bumps" or similar.

🤖 Prompt for AI Agents
In @.npmrc around lines 6 - 7, The comment above the git-tag-version=false
setting is misleading; change it to accurately state its effect (it disables
automatic git commit/tag creation during npm version bumps). Update the comment
that precedes git-tag-version=false to something like "Disable automatic git
commit and tag creation during npm version bumps" so it correctly documents the
purpose of the git-tag-version=false config.

@github-actions github-actions bot requested a deployment to Preview (perps) January 15, 2026 10:00 Abandoned
@github-actions github-actions bot requested a deployment to Preview (perps) January 15, 2026 10:09 Abandoned
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@package.json`:
- Line 99: The project includes "next-themes" in package.json but no
ThemeProvider is configured while useTheme() is called in the Sonner component;
either remove the dependency and all uses/imports of useTheme from
src/apps/perps/components/ui/sonner.tsx, or add the missing ThemeProvider
initialization by wrapping your app root with ThemeProvider from "next-themes"
(e.g., in your App or RootLayout component) so useTheme() receives a defined
theme; update imports accordingly and remove unused package if you choose the
first option.
♻️ Duplicate comments (1)
package.json (1)

32-32: Dependency updated as previously suggested.

The @nktkas/hyperliquid package has been updated to ^0.30.2 as recommended in the prior review. This addresses the patch update.

🧹 Nitpick comments (1)
package.json (1)

8-8: Build memory increase is reasonable, but single-quote syntax may fail on Windows.

The 8GB heap limit is appropriate for the expanded codebase. However, the single-quoted NODE_OPTIONS='--max-old-space-size=8192' syntax is POSIX-specific and will fail on Windows CMD/PowerShell.

If Windows development/CI environments are used, consider using cross-env or a platform-agnostic approach:

♻️ Suggested cross-platform fix
-    "build": "NODE_OPTIONS='--max-old-space-size=8192' VITE_COMMIT_SHA=$CF_PAGES_COMMIT_SHA VITE_VERSION=$npm_package_version vite build",
+    "build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 VITE_COMMIT_SHA=$CF_PAGES_COMMIT_SHA VITE_VERSION=$npm_package_version vite build",

Add cross-env to devDependencies:

+    "cross-env": "^7.0.3",

If the project only targets Unix-like CI (e.g., Cloudflare Pages), this can be ignored.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 578c895 and ce7d078.

📒 Files selected for processing (1)
  • package.json
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: unit-tests
  • GitHub Check: lint
  • GitHub Check: build
  • GitHub Check: Cloudflare Pages: x
🔇 Additional comments (4)
package.json (4)

26-26: LGTM — @hookform/resolvers is compatible with the installed react-hook-form version.

Version ^5.2.2 of @hookform/resolvers requires react-hook-form v7.x, which is satisfied by ^7.67.0 at line 111. This supports the new form validation flows in TradeForm and related components.


93-93: LGTM — lightweight-charts addition is appropriate.

Version ^5.1.0 is a well-maintained charting library suitable for the SparklineChart and trading UI components introduced in this PR.


134-135: LGTM — zod addition supports schema validation for trading forms.

Version ^3.25.76 is appropriate for use with @hookform/resolvers and the TradeForm validation flows. Note that zod v4.x is available with breaking changes — staying on v3.x is reasonable for stability.


99-100: Remove patch-package from dependencies — it's no longer in use.

The patches/ directory does not exist and the postinstall script has been removed. Since patch-package is no longer applied during installation, it should be removed from devDependencies at line 100 to keep dependencies clean.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

"luxon": "3.5.0",
"mime": "4.0.4",
"moment": "2.30.1",
"next-themes": "^0.4.6",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how next-themes is used in the codebase
echo "=== Searching for next-themes usage ==="
rg -n "next-themes" --type ts --type tsx -C 3 2>/dev/null || rg -n "next-themes" -g "*.ts" -g "*.tsx" -C 3

echo ""
echo "=== Checking ThemeProvider setup ==="
rg -n "ThemeProvider" -g "*.tsx" -g "*.ts" -C 5

Repository: pillarwallet/x

Length of output: 32303


🏁 Script executed:

cat -n src/apps/perps/components/ui/sonner.tsx

Repository: pillarwallet/x

Length of output: 1161


🏁 Script executed:

# Search for next-themes ThemeProvider setup
rg "ThemeProvider.*next-themes" -g "*.ts" -g "*.tsx"
rg "from.*next-themes" -g "*.ts" -g "*.tsx"

Repository: pillarwallet/x

Length of output: 137


Remove unused next-themes or add required ThemeProvider initialization.

next-themes is imported and useTheme() is called in src/apps/perps/components/ui/sonner.tsx, but no ThemeProvider from next-themes is configured anywhere in the codebase. The useTheme() hook requires a ThemeProvider wrapper to function; without it, the theme will be undefined. Either remove the next-themes dependency (since the project uses styled-components for theming) or add the required ThemeProvider initialization.

🤖 Prompt for AI Agents
In `@package.json` at line 99, The project includes "next-themes" in package.json
but no ThemeProvider is configured while useTheme() is called in the Sonner
component; either remove the dependency and all uses/imports of useTheme from
src/apps/perps/components/ui/sonner.tsx, or add the missing ThemeProvider
initialization by wrapping your app root with ThemeProvider from "next-themes"
(e.g., in your App or RootLayout component) so useTheme() receives a defined
theme; update imports accordingly and remove unused package if you choose the
first option.

@github-actions github-actions bot requested a deployment to Preview (perps) January 15, 2026 13:06 Abandoned
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants