Skip to content

Conversation

@Charon-Fan
Copy link
Contributor

@Charon-Fan Charon-Fan commented Oct 30, 2025

Summary by CodeRabbit

  • New Features

    • Major Earn redesign: tabbed Earn Home (Assets, Portfolio, FAQs), Protocols list, Protocol Details page with APY history chart, Manage Position page, Banner carousel and BannerItemV2.
    • New responsive TableList and LightweightChart components; Network avatar group "overlapped"/"spread" variants; new "link" button style.
  • Improvements

    • Streamlined Earn navigation and share links, targeted portfolio refresh events, unified loading skeletons, responsive layouts, and richer FAQ/overview content.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 30, 2025

Walkthrough

Adds Earn v2 backend endpoints, new Earn UI (pages, tabs, tables, charts), many hooks and helpers, routing updates to use Earn tab routes, portfolio/event refresh events, charting utilities (LightweightChart), and various staking/manage-page components and types.

Changes

Cohort / File(s) Summary
Service Layer – Earn v2
packages/kit-bg/src/services/ServiceStaking.ts
Adds V2 types and endpoints: getEarnSummaryV2, getManagePage, fetchInvestmentDetailV2, fetchAirdropInvestmentDetail, getAvailableAssetsV2, getApyHistory; normalizes provider/vault handling and expands public types.
Routing & Navigation
packages/kit/src/routes/Tab/Earn/router.ts, packages/shared/src/routes/tabEarn.ts, packages/shared/src/routes/staking.ts, packages/kit/src/provider/Bootstrap.tsx, packages/kit/src/components/TabPageHeader/components/WebHeaderNavigation/WebHeaderNavigation.tsx
Adds Earn tab routes (EarnProtocols, EarnProtocolDetails, EarnProtocolDetailsShare), adds ManagePosition modal route, and updates navigation calls to switch to Earn tab with target screens.
Earn Root & Pages
packages/kit/src/views/Earn/EarnHome.tsx, packages/kit/src/views/Earn/pages/EarnProtocols/index.tsx, packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx, packages/kit/src/views/Earn/components/EarnPageContainer.tsx
Reworks EarnHome into container/tabs, adds EarnProtocols list and EarnProtocolDetails page, introduces EarnPageContainer and new breadcrumb layout.
Earn Components: Banners / Overview / Tabs
packages/kit/src/views/Earn/components/BannerV2.tsx, packages/kit/src/views/Earn/components/BannerItemV2.tsx, packages/kit/src/views/Earn/components/Overview.tsx, packages/kit/src/views/Earn/components/EarnBlockedOverview.tsx, packages/kit/src/views/Earn/components/EarnMainTabs.tsx, packages/kit/src/views/Earn/components/ProtocolsTabContent.tsx
Adds BannerV2 + BannerItemV2 carousel, Overview and blocked overview components, EarnMainTabs and ProtocolsTabContent for tabbed Earn UI.
Earn Components: Lists / Tables / FAQ
packages/kit/src/components/ListView/TableList.tsx, packages/kit/src/views/Earn/components/AvailableAssetsTabViewList.tsx, packages/kit/src/views/Earn/components/FAQContent.tsx, (removed) packages/kit/src/views/Earn/components/FAQPanel.tsx
New generic TableList (responsive, sortable, expandable) and TableList-driven AvailableAssets view; FAQPanel removed and replaced by FAQContent accordion.
Earn Portfolio & Recommended
packages/kit/src/views/Earn/components/PortfolioTabContent.tsx, packages/kit/src/views/Earn/components/Recommended.tsx
Adds portfolio table-driven UI with expandable rows and actions; adds Recommended assets grid with skeletons and navigation.
Earn Hooks & Data
packages/kit/src/views/Earn/hooks/*
New hooks: useBannerInfo, useFAQListInfo, useBlockRegion, useEarnAccounts, useEarnPortfolio, useToTokenProviderListPage, usePortfolioAction — provide fetching, polling, state wiring, navigation helpers, and action handlers.
Protocol Details & Charts
packages/kit/src/views/Earn/pages/EarnProtocolDetails/*, packages/kit/src/components/LightweightChart/*
Adds ApyChart, protocol-detail hooks (useProtocolDetailData, breadcrumb hook), full EarnProtocolDetails page, and LightweightChart (web + native) with config, HTML template, and utils.
Earn Utilities & Navigation Helpers
packages/kit/src/views/Earn/earnUtils.ts, packages/kit/src/views/Earn/components/showProtocolListDialog.tsx
Adds generateEarnShareLink and toTokenProviderListPage, changes deeplink/detail navigation to Earn tab flows, and silences some console logs.
ManagePosition / Staking Manage UI
packages/kit/src/views/Staking/pages/ManagePosition/*, packages/kit/src/views/Staking/pages/ManagePosition/hooks/*
Adds ManagePosition page wrapper, StakeSection, WithdrawSection, useEarnAccount, useManagePage, and useProtocolDetails hooks; navigation and provider wrappers updated.
Staking Hooks / Claim Flow / Events
packages/kit/src/views/Staking/hooks/useUniversalHooks.ts, packages/kit/src/views/Staking/pages/ProtocolDetails/useHandleClaim.ts, packages/kit/src/views/Staking/pages/ProtocolDetails/useHandleClaim.ts
Emits RefreshEarnPortfolioItem/RefreshEarnPortfolio after stake/withdraw/claim; useUniversalClaim/useHandleClaim extended to accept/forward optional portfolioSymbol; claim navigation adjusted to modal flows.
Action Components & Props
packages/kit/src/views/Staking/components/ProtocolDetails/*
EarnActionIcon gains optional trigger override; GridItem accepts descriptionComponent; NoAddressWarning.onCreateAddress becomes async-capable; UniversalStake/UniversalWithdraw gain isDisabled gating tweaks.
State & Jotai Earn Atom
packages/kit/src/states/jotai/contexts/earn/atoms.ts, packages/kit/src/states/jotai/contexts/earn/actions.ts
Earn atom now includes recommendedTokens and banners; actions to get/update recommended tokens and banners added; persistence/sync tweaks.
Event Bus & Shared Types
packages/shared/src/eventBus/*, packages/shared/src/types/staking.ts
Adds events RefreshEarnPortfolioItem and RefreshEarnPortfolio; expands staking types with V2 investments, manage page response, APY history, and related structures.
Shared Utilities
packages/shared/src/utils/earnUtils.ts, packages/shared/src/utils/routeUtils.ts
Adds convertEarnTokenToIToken helper and allowlist entry for ManagePosition route.
Charting & Market Chart Utils
packages/kit/src/views/Market/components/Chart/*
Adds theme-driven chart color wiring and updated chart styling/formatting options.
Small UI & Primitives
packages/components/src/content/Breadcrumb/index.tsx, packages/components/src/primitives/Button/index.tsx, packages/kit/src/components/NetworkAvatar/NetworkAvatar.tsx, packages/kit/src/views/Staking/components/StakingFormWrapper/index.tsx
Breadcrumb item styling adjusted; new Button link variant; NetworkAvatarGroup gets variant/maxVisible; YStack gap increased in StakingFormWrapper.
Misc: Navigation call-site updates & removals
numerous files (e.g., packages/kit/src/views/Market/components/tradeHook.tsx, packages/kit/src/views/Home/components/WalletActions/WalletActionEarn.tsx, packages/kit/src/views/Staking/pages/...)
Replaces modal-based protocol navigation with Earn tab routes across many call sites; FAQPanel removed; console logs reduced in protocol list dialog.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant UI as App UI
    participant Nav as Navigation
    participant EarnTab as Earn Tab
    participant API as Backend API
    participant Store as Jotai Store

    User->>UI: Open Earn / Tap protocol
    UI->>Nav: switchTab(ETabRoutes.Earn, { screen: ETabEarnRoutes.EarnHome })
    Nav->>EarnTab: mount EarnHome
    EarnTab->>API: fetch banners/faqs/accounts (useBannerInfo, useFAQListInfo, useEarnAccounts)
    API-->>Store: update banners/recommendedTokens
    Store-->>EarnTab: notify changes

    User->>EarnTab: Open Protocols
    EarnTab->>API: getProtocolList
    API-->>EarnTab: protocol list
    EarnTab->>UI: render TableList

    User->>UI: Select protocol row
    UI->>Nav: switchTab(ETabRoutes.Earn, { screen: ETabEarnRoutes.EarnProtocolDetails, params })
    Nav->>EarnTab: show EarnProtocolDetails
    EarnTab->>API: getProtocolDetailsV2, getApyHistory
    API-->>EarnTab: detail + apy history
    EarnTab->>UI: render details + ApyChart

    User->>UI: Stake / Withdraw
    UI->>Nav: open ManagePosition (modal/route)
    Nav->>API: getManagePage
    UI->>API: submit tx
    API-->>Store: emit RefreshEarnPortfolioItem / RefreshEarnPortfolio
    Store-->>EarnTab: portfolio hooks refresh
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Areas to focus review on:

  • packages/kit-bg/src/services/ServiceStaking.ts — v2 request/response shapes, error handling, provider/vault normalization.
  • packages/kit/src/views/Earn/hooks/useEarnPortfolio.ts — batching logic, incremental merging, stale-request safety.
  • packages/kit/src/components/ListView/TableList.tsx — responsive visibility, sorting, expandable row rendering and skeleton behavior.
  • LightweightChart (web + native) — HTML template, WebView messaging, hover event handling, memory/cleanup.
  • Routing changes — verify all call sites migrated from modal-based protocol routes to Earn tab routes and param shapes.
  • Event bus additions — ensure consumers handle new event payloads (RefreshEarnPortfolioItem, RefreshEarnPortfolio).

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.35% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: earn redesign' is clear, concise, and accurately reflects the primary change across all modified files—a comprehensive redesign of the Earn feature with new components, routing, and UI structures.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/earn-redesign

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@revan-zhang
Copy link
Contributor

revan-zhang commented Oct 30, 2025

Snyk checks have passed. No issues have been found so far.

Status Scanner Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@Charon-Fan Charon-Fan changed the title Feat/earn redesign feat: earn redesign Oct 31, 2025
Charon-Fan and others added 21 commits October 31, 2025 09:49
…bContent for improved layout and currency formatting
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

♻️ Duplicate comments (3)
packages/kit/src/views/Earn/components/Overview.tsx (1)

126-191: Guard the map calls and length access - this was flagged before.

The past review flagged unguarded .map() calls, but the issue remains. If distributed or undistributed is null/undefined, the bare .map() throws. The isEmpty checks only hide the section headers, not the maps themselves.

Additionally, line 133 accesses rebateData.distributed.length without optional chaining, which will also throw.

Apply this diff:

-            {rebateData?.distributed.map((item, index) => {
+            {(rebateData?.distributed ?? []).map((item, index) => {
               const needDivider =
-                index === rebateData.distributed.length - 1 &&
+                index === (rebateData?.distributed ?? []).length - 1 &&
                 !isEmpty(rebateData?.undistributed);

               return itemRender({
                 key: index,
                 needDivider,
                 children: (
                   <>
                     <XStack ai="center" gap="$2.5">
                       <Token
                         size="sm"
                         borderRadius="$2"
                         tokenImageUri={item.token.logoURI}
                       />
                       <EarnText
                         size="$bodyMdMedium"
                         color="$text"
                         text={item.title}
                       />
                     </XStack>
                     <EarnActionIcon
                       actionIcon={item.button}
                       onHistory={handleHistoryClick}
                     />
                   </>
                 ),
               });
             })}
             {isEmpty(rebateData?.undistributed) ? null : (
               <SizableText mx="$5" size="$bodyMdMedium" color="$textSubdued">
                 {intl.formatMessage({ id: ETranslations.referral_undistributed })}
               </SizableText>
             )}
-            {rebateData?.undistributed.map((item, index) => {
+            {(rebateData?.undistributed ?? []).map((item, index) => {
packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx (2)

321-334: Fix countdown visibility window.

The alert only shows after the end time has passed. The comparison should be flipped so the alert displays while now is between startTime and endTime.

             {detailInfo?.countDownAlert?.startTime &&
             detailInfo?.countDownAlert?.endTime &&
             now > detailInfo.countDownAlert.startTime &&
-            detailInfo.countDownAlert.endTime < now ? (
+            detailInfo.countDownAlert.endTime > now ? (

517-523: Remove incorrect await on void function.

Line 519 awaits onCreateAddress() which returns void, not a Promise. This causes a linter error.

   const handleCreateAddress = useCallback(async () => {
     if (onCreateAddress) {
-      await onCreateAddress();
+      onCreateAddress();
     }
     await refreshManageAccount();
     await refreshManageData();
   }, [onCreateAddress, refreshManageAccount, refreshManageData]);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 9f5f1f9 and 2c8bc64.

📒 Files selected for processing (2)
  • packages/kit/src/views/Earn/components/Overview.tsx (1 hunks)
  • packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx (1 hunks)
🧰 Additional context used
🪛 ESLint
packages/kit/src/views/Earn/components/Overview.tsx

[error] 163-163: Replace ·id:·ETranslations.referral_undistributed with ⏎··················id:·ETranslations.referral_undistributed,⏎···············

(prettier/prettier)

⏰ 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). (3)
  • GitHub Check: lint (20.x)
  • GitHub Check: unittest (20.x)
  • GitHub Check: Analyze (javascript-typescript)
🔇 Additional comments (1)
packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx (1)

755-777: No issues found. Hooks handle empty accountId gracefully.

Both downstream hooks defend against empty accountId:

  • useManagePage includes a guard (if (!accountId) { return undefined; }) that catches empty strings and prevents API calls
  • useProtocolDetailData passes accountId to services and returns undefined when data loads empty, following graceful degradation

The empty string fallback in the code under review flows safely through both hooks without causing silent failures.

<XStack flexWrap="wrap" m="$-5" p="$2">
{intro.items.map((cell) => (
<GridItem
key={cell.title.text}
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider using a more unique key.

Using cell.title.text as a key assumes titles are unique. If they're not, React will throw warnings or behave incorrectly.

Use an index or a unique identifier if available:

-            {intro.items.map((cell) => (
+            {intro.items.map((cell, idx) => (
               <GridItem
-                key={cell.title.text}
+                key={`${cell.title.text}-${idx}`}
🤖 Prompt for AI Agents
In packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx around line
147 the list item uses key={cell.title.text}, which can collide if titles
repeat; change the key to a truly unique value (preferably a stable id on the
cell object, e.g., cell.id or cell.key) and if no unique id exists fall back to
using the index from the map (e.g., (cell, i) =>
key={`${cell.title.text}-${i}`}) to ensure uniqueness and stable rendering.

Comment on lines +217 to +264
{risk.items?.map((item) => (
<>
<XStack ai="center" gap="$3" key={item.title.text}>
<YStack flex={1} gap="$2">
<XStack ai="center" gap="$2">
<XStack
ai="center"
jc="center"
w="$6"
h="$6"
borderRadius="$1"
>
<EarnIcon
icon={item.icon}
size="$6"
color="$iconCaution"
/>
</XStack>
<EarnText text={item.title} size="$bodyMdMedium" />
</XStack>
<EarnText
text={item.description}
size="$bodyMd"
color={item.description.color || '$textSubdued'}
/>
</YStack>
<EarnActionIcon
title={item.title.text}
actionIcon={item.actionButton}
/>
</XStack>

{item.list?.length ? (
<YStack gap="$1">
{item.list.map((i, indexOfList) => (
<XStack key={indexOfList} gap="$1">
<EarnIcon icon={i.icon} size="$4" color="$iconCaution" />
<EarnText
text={i.title}
size="$bodySm"
color="$textCaution"
/>
</XStack>
))}
</YStack>
) : 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

Move key prop to Fragment.

When mapping returns a Fragment, the key must be on the Fragment itself, not on its children. This prevents React reconciliation issues.

Apply this fix:

           {risk.items?.map((item) => (
-            <>
+            <Fragment key={item.title.text}>
-              <XStack ai="center" gap="$3" key={item.title.text}>
+              <XStack ai="center" gap="$3">
                 <YStack flex={1} gap="$2">
...
-            </>
+            </Fragment>

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx around lines
217 to 264, the map returns a Fragment but the key is currently set on the inner
XStack which can break React reconciliation; move the key to the Fragment (use a
keyed fragment or an explicit <Fragment key={...}>), remove the key from the
child XStack, and keep using a stable unique value (e.g., item.title.text) as
the key so each mapped Fragment has the key at the top level.

Comment on lines +788 to +806
const { accountId, networkId, indexedAccountId, symbol, provider, vault } =
resolvedParams;

const {
earnAccount,
detailInfo,
tokenInfo,
protocolInfo,
isLoading,
refreshData,
refreshAccount,
} = useProtocolDetailData({
accountId,
networkId,
indexedAccountId: indexedAccount?.id,
symbol,
provider,
vault,
});
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

Fix inconsistent indexedAccountId usage.

Line 788 destructures indexedAccountId from resolvedParams, but line 802 passes indexedAccount?.id from the activeAccount hook instead. This creates inconsistency—share-link navigation might intend a different account context than what's actually used.

Use the resolved param consistently:

-  const { accountId, networkId, indexedAccountId, symbol, provider, vault } =
+  const { accountId, networkId, indexedAccountId: resolvedIndexedAccountId, symbol, provider, vault } =
     resolvedParams;
   
   const {
     earnAccount,
     detailInfo,
     tokenInfo,
     protocolInfo,
     isLoading,
     refreshData,
     refreshAccount,
   } = useProtocolDetailData({
     accountId,
     networkId,
-    indexedAccountId: indexedAccount?.id,
+    indexedAccountId: resolvedIndexedAccountId,
     symbol,
     provider,
     vault,
   });
📝 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 { accountId, networkId, indexedAccountId, symbol, provider, vault } =
resolvedParams;
const {
earnAccount,
detailInfo,
tokenInfo,
protocolInfo,
isLoading,
refreshData,
refreshAccount,
} = useProtocolDetailData({
accountId,
networkId,
indexedAccountId: indexedAccount?.id,
symbol,
provider,
vault,
});
const { accountId, networkId, indexedAccountId: resolvedIndexedAccountId, symbol, provider, vault } =
resolvedParams;
const {
earnAccount,
detailInfo,
tokenInfo,
protocolInfo,
isLoading,
refreshData,
refreshAccount,
} = useProtocolDetailData({
accountId,
networkId,
indexedAccountId: resolvedIndexedAccountId,
symbol,
provider,
vault,
});
🤖 Prompt for AI Agents
In packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx around lines
788 to 806, the code destructures indexedAccountId from resolvedParams but then
calls useProtocolDetailData with indexedAccount?.id (from activeAccount)
creating an inconsistency; replace the indexedAccount?.id argument with the
already-destructured indexedAccountId so the resolved route param is used
consistently when calling useProtocolDetailData, and remove any fallback to
activeAccount here (or explicitly pass a fallback only if intended) to ensure
share-link navigation uses the resolved account context.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
packages/kit/src/views/Earn/EarnHome.tsx (4)

74-108: Consider extracting hardcoded key construction.

The template literal keys (availableAssets-${EAvailableAssetsTypeEnum.X}) are repeated and fragile. If the enum changes, these keys break silently.

Extract to a helper function or constants for maintainability.

Example:

const getAvailableAssetsKey = (type: EAvailableAssetsTypeEnum) => 
  `availableAssets-${type}`;

const keys = [
  getAvailableAssetsKey(EAvailableAssetsTypeEnum.All),
  getAvailableAssetsKey(EAvailableAssetsTypeEnum.StableCoins),
  getAvailableAssetsKey(EAvailableAssetsTypeEnum.NativeTokens),
];

110-168: Add error handling for async operations.

The getEarnAccount call (line 123) lacks try-catch. Malformed URLs could also break the string parsing (lines 114-120).

Wrap in try-catch to prevent unhandled rejections and crashes.

 const onBannerPress = useCallback(
   async ({ hrefType, href }: IDiscoveryBanner) => {
+    try {
       if (account || indexedAccount) {
         if (href.includes('/defi/staking')) {
           const [path, query] = href.split('?');
           const paths = path.split('/');
           const provider = paths.pop();
           const symbol = paths.pop();
           const params = new URLSearchParams(query);
           const networkId = params.get('networkId');
           const vault = params.get('vault');
           if (provider && symbol && networkId) {
             const earnAccount =
               await backgroundApiProxy.serviceStaking.getEarnAccount({
                 indexedAccountId: indexedAccount?.id,
                 accountId: account?.id ?? '',
                 networkId,
               });
             // ... rest of logic
           }
           return;
         }
         if (hrefType === 'external') {
           openUrlExternal(href);
         } else {
           openUrlInApp(href);
         }
       } else {
         await accountSelectorActions.current.showAccountSelector({
           navigation,
           activeWallet: undefined,
           num: 0,
           sceneName: EAccountSelectorSceneName.home,
         });
       }
+    } catch (error) {
+      console.error('Failed to handle banner press:', error);
+      // Consider showing user-facing error message
+    }
   },
   [account, accountSelectorActions, indexedAccount, navigation],
 );

201-208: Clarify the height calculation.

Line 206 comment says "without arbitrary adjustments" but then subtracts 20. Why?

Add a comment explaining the -20 offset or remove it if unnecessary.


224-324: Platform rendering divergence is complex.

Native (lines 224-284) and non-native (lines 286-324) paths have significantly different structures. The native version uses absolute positioning and nested YStacks, while non-native uses EarnPageContainer.

This works but makes maintenance harder. Consider extracting shared layout logic if possible.

♻️ Duplicate comments (1)
packages/kit/src/views/Earn/hooks/useEarnPortfolio.ts (1)

410-414: Let zeroed-out positions drop from the portfolio.

We add every key to fetchedKeys before the fetch resolves, but fetchInvestmentDetail returns null for zero balances (line 295-298). Those keys stay in fetchedKeys, so the final sweep (lines 471-475) keeps stale entries and users still see positions after cashing out. Only mark a key as fetched when we actually have data.

Apply this diff:

-      const fetchPromises = pairsWithType.map(({ params, isAirdrop }) => {
-        const key = createInvestmentKey(params);
-        fetchedKeys.add(key);
-        return fetchInvestmentDetail(params, isAirdrop, requestId);
-      });
+      const fetchPromises = pairsWithType.map(({ params, isAirdrop }) => {
+        return fetchInvestmentDetail(params, isAirdrop, requestId);
+      });

And add the key tracking inside processResult:

       const processResult = (
         result: Awaited<ReturnType<typeof fetchInvestmentDetail>>,
       ) => {
         if (!result || isRequestStale(requestId)) return;
 
         const { key, investment } = result;
+        fetchedKeys.add(key);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 2c8bc64 and 84db51d.

📒 Files selected for processing (5)
  • packages/kit/src/views/Discovery/pages/Browser/Browser.native.tsx (1 hunks)
  • packages/kit/src/views/Earn/EarnHome.tsx (8 hunks)
  • packages/kit/src/views/Earn/hooks/useEarnPortfolio.ts (1 hunks)
  • packages/kit/src/views/Home/components/WalletActions/WalletActionEarn.tsx (3 hunks)
  • packages/kit/src/views/Market/components/tradeHook.tsx (2 hunks)
⏰ 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). (3)
  • GitHub Check: lint (20.x)
  • GitHub Check: unittest (20.x)
  • GitHub Check: Analyze (javascript-typescript)
🔇 Additional comments (15)
packages/kit/src/views/Home/components/WalletActions/WalletActionEarn.tsx (3)

12-12: LGTM!

The routing constants import aligns with the new tab-based navigation pattern.


116-123: Code is correct. Route exists and parameters match.

The EarnProtocols route is properly defined with the correct parameter types. Your code passes symbol and filterNetworkId, which match the expected interface exactly. No changes needed.


100-114: Route verified—no issues found.

The ETabEarnRoutes.EarnProtocolDetails route is properly defined and configured to accept all passed parameters: networkId, accountId, indexedAccountId, symbol, provider, and vault. The route component handles these params via ITabEarnParamList. The navigation pattern here matches usage across the codebase.

packages/kit/src/views/Market/components/tradeHook.tsx (2)

9-13: LGTM! Import changes align with navigation refactor.

The imports correctly reflect the shift from modal-based to tab-based Earn navigation.


258-265: Param structure is correct—no issues found.

The params passed (symbol and filterNetworkId) match the route definition exactly. filterNetworkId is optional, so passing networkId is fine. The tab navigation is a deliberate UX change, not a mistake.

packages/kit/src/views/Earn/hooks/useEarnPortfolio.ts (10)

27-34: LGTM!

Key generation logic is clear and handles optional vault correctly.


35-53: LGTM!

Utility functions are pure and handle BigNumber arithmetic correctly.


71-95: LGTM!

Asset enrichment and investment merging logic are correct. BigNumber arithmetic ensures precision when summing fiat values.


97-114: LGTM!

Protocol aggregation logic correctly merges investments by protocol code and sorts by value.


117-167: LGTM!

Investment state management correctly filters, sorts, and tracks investments. Airdrops are kept even with zero value as intended.


169-210: LGTM!

Account state tracking and request ID management correctly prevent race conditions when accounts change.


234-239: LGTM!

Effect correctly clears stale portfolio data when account changes.


241-326: LGTM!

Investment detail fetching correctly handles airdrops vs. normal investments, filters zero-balance positions, and guards against stale requests.


384-480: LGTM!

RAF throttling for incremental updates and final cleanup logic are well-designed. The code correctly handles partial vs. full refresh scenarios.


495-524: LGTM!

Hook integration and public API are clean. Auto-revalidation and manual refresh with filtering options provide good UX.

Comment on lines 380 to 390
<Stack
flex={1}
display={
selectedHeaderTab === ETranslations.global_earn ? undefined : 'none'
}
>
<EarnHomeWithProvider
showHeader={false}
showContent={selectedHeaderTab === ETranslations.global_earn}
/>
</Stack>
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Simplify redundant conditional and consider performance optimization.

Two issues:

  1. Redundant conditional logic: The Stack's display prop and showContent prop both check selectedHeaderTab === ETranslations.global_earn. When the Stack has display: 'none', the showContent check is redundant unless it controls internal data fetching or computation within EarnHomeWithProvider.

  2. Missing Freeze component: The browser content section (lines 342-379) uses Freeze components to optimize performance when content isn't visible. Consider wrapping EarnHomeWithProvider with Freeze for consistency and to prevent unnecessary work when the tab isn't selected.

Consider this pattern for consistency:

 <Stack
   flex={1}
   display={
     selectedHeaderTab === ETranslations.global_earn ? undefined : 'none'
   }
 >
+  <Freeze freeze={selectedHeaderTab !== ETranslations.global_earn}>
     <EarnHomeWithProvider
       showHeader={false}
-      showContent={selectedHeaderTab === ETranslations.global_earn}
     />
+  </Freeze>
 </Stack>

Or if showContent serves a distinct purpose, document why both conditionals are needed.

📝 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
<Stack
flex={1}
display={
selectedHeaderTab === ETranslations.global_earn ? undefined : 'none'
}
>
<EarnHomeWithProvider
showHeader={false}
showContent={selectedHeaderTab === ETranslations.global_earn}
/>
</Stack>
<Stack
flex={1}
display={
selectedHeaderTab === ETranslations.global_earn ? undefined : 'none'
}
>
<Freeze freeze={selectedHeaderTab !== ETranslations.global_earn}>
<EarnHomeWithProvider
showHeader={false}
/>
</Freeze>
</Stack>

import { Image, SizableText, Stack, useMedia } from '@onekeyhq/components';
import type { IDiscoveryBanner } from '@onekeyhq/shared/types/discovery';

const BANNER_TITLE_OFFSET = {
Copy link
Collaborator

Choose a reason for hiding this comment

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

这类没用到的变量都可以清理下,还有下面的 media


return (
<YStack>
{investments.map((item, index) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

这个写法,会不会有长列表的滚动问题?

token,
...rest
}: { token?: IRecommendAsset } & IYStackProps) {
const accountInfo = useActiveAccount({ num: 0 });
Copy link
Collaborator

Choose a reason for hiding this comment

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

事件和账户信息都从父组件传入,减少子组件的事件绑定数量,和 hooks 变动导致的重渲染

import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy';
import { usePromiseResult } from '@onekeyhq/kit/src/hooks/usePromiseResult';

export const useEarnAccount = ({
Copy link
Collaborator

Choose a reason for hiding this comment

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

两个同名的 hooks 了

Copy link
Collaborator

@originalix originalix left a comment

Choose a reason for hiding this comment

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

移动端进入 2 级页面,得像 Market 一样隐藏下 TabBar?跟 pm 确认下

@Charon-Fan
Copy link
Contributor Author

移动端进入 2 级页面,得像 Market 一样隐藏下 TabBar?跟 pm 确认下

ok tabbar 这期间应该改版过,我问下我这边咋适配

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

♻️ Duplicate comments (7)
packages/kit/src/views/Earn/hooks/useEarnAccounts.ts (2)

31-34: Remove redundant account existence check.

The inner guard duplicates the outer check at lines 22-24. Since fetchAndUpdateOverview is only called when an account exists, this check is unnecessary.

Apply this diff:

   const fetchAndUpdateOverview = async () => {
-    if (!isAccountExists && !indexedAccount?.id) {
-      return;
-    }
-
     // Fetch account assets (contains accounts array)

61-67: Document the 350ms delay rationale.

The 350ms wait when cached data exists lacks explanation. Clarify why this throttle is needed.

Add a comment or extract as a named constant:

+ // Throttle refetch when cached data exists to reduce server load
  const earnAccountData = actions.current.getEarnAccount(totalFiatMapKey);
  if (earnAccountData) {
    await timerUtils.wait(350);

Or:

+ const CACHED_DATA_REFETCH_DELAY_MS = 350;
+
  export const useEarnAccounts = () => {
    // ...
    if (earnAccountData) {
-     await timerUtils.wait(350);
+     await timerUtils.wait(CACHED_DATA_REFETCH_DELAY_MS);
packages/kit/src/views/Earn/hooks/useEarnPortfolio.ts (2)

53-67: Remove unused function.

filterByOptions is never called. The filtering logic is duplicated inline at lines 367-377. Remove this dead code.

Apply this diff:

-const filterByOptions = <
-  T extends { provider: string; networkId: string; symbol: string },
->(
-  list: Array<T>,
-  options?: IRefreshOptions,
-): T[] => {
-  if (!options) return list;
-
-  return list.filter((item) => {
-    if (options.provider && item.provider !== options.provider) return false;
-    if (options.networkId && item.networkId !== options.networkId) return false;
-    if (options.symbol && item.symbol !== options.symbol) return false;
-    return true;
-  });
-};
-

408-412: Fix fetchedKeys tracking to let zeroed positions drop.

Keys are added to fetchedKeys before the fetch completes (line 410), but fetchInvestmentDetail returns null for zero balances. Those keys remain in fetchedKeys, so the final sweep keeps stale entries—users still see value after cashing out. Only mark a key as fetched when valid data arrives.

Move fetchedKeys.add(key) into processResult after confirming valid data:

  const fetchPromises = pairsWithType.map(({ params, isAirdrop }) => {
    const key = createInvestmentKey(params);
-   fetchedKeys.add(key);
    return fetchInvestmentDetail(params, isAirdrop, requestId);
  });

  // Process results incrementally
  const processResult = (
    result: Awaited<ReturnType<typeof fetchInvestmentDetail>>,
  ) => {
    if (!result || isRequestStale(requestId)) return;

    const { key, investment } = result;
+   fetchedKeys.add(key);
packages/kit/src/views/Earn/components/Recommended.tsx (2)

76-94: Guard against empty protocols array.

Lines 82 and 88 access token.protocols[0]?.networkId without verifying the array has elements. Add a length check.

Apply this diff:

  const onPress = useCallback(async () => {
    if (token) {
+     if (!token.protocols || token.protocols.length === 0) {
+       return;
+     }
      const earnAccount =
        await backgroundApiProxy.serviceStaking.getEarnAccount({

101-122: Consider semantic button element for accessibility.

Line 102 uses role="button" on a YStack. While functional, a semantic Button component would provide better built-in accessibility.

If the UI framework's Button supports the same styling, replace YStack with Button and remove the explicit role attribute.

packages/kit/src/views/Earn/components/Overview.tsx (1)

130-133: Guard rebate lists before accessing properties.

The .map() and .length calls will throw if distributed or undistributed are null/undefined. Use fallback arrays.

Apply this diff:

-            {rebateData?.distributed.map((item, index) => {
+            {(rebateData?.distributed ?? []).map((item, index) => {
               const needDivider =
-                index === rebateData.distributed.length - 1 &&
+                index === (rebateData?.distributed ?? []).length - 1 &&
                 !isEmpty(rebateData?.undistributed);
@@
-            {rebateData?.undistributed.map((item, index) => {
+            {(rebateData?.undistributed ?? []).map((item, index) => {

Also applies to: 167-167

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f52e71e and 23f70b6.

📒 Files selected for processing (9)
  • packages/kit-bg/src/services/ServiceStaking.ts (9 hunks)
  • packages/kit/src/states/jotai/contexts/earn/actions.ts (6 hunks)
  • packages/kit/src/states/jotai/contexts/earn/atoms.ts (2 hunks)
  • packages/kit/src/views/Earn/components/Overview.tsx (1 hunks)
  • packages/kit/src/views/Earn/components/Recommended.tsx (1 hunks)
  • packages/kit/src/views/Earn/hooks/useBannerInfo.ts (1 hunks)
  • packages/kit/src/views/Earn/hooks/useEarnAccounts.ts (1 hunks)
  • packages/kit/src/views/Earn/hooks/useEarnPortfolio.ts (1 hunks)
  • packages/shared/types/staking.ts (10 hunks)
🧰 Additional context used
🪛 Biome (2.1.2)
packages/kit/src/views/Earn/components/Recommended.tsx

[error] 102-102: The elements with this role can be changed to the following elements:

For examples and more information, see WAI-ARIA Roles

(lint/a11y/useSemanticElements)

⏰ 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: lint (20.x)
  • GitHub Check: unittest (20.x)
🔇 Additional comments (7)
packages/kit/src/states/jotai/contexts/earn/atoms.ts (2)

39-39: Good type safety improvement.

Replacing any with a union type makes the setter more type-safe.


24-25: LGTM! Consistent initialization.

The new fields follow the same pattern as existing fields with proper empty array fallbacks during initialization.

Also applies to: 46-47

packages/kit/src/states/jotai/contexts/earn/actions.ts (3)

48-48: Good cleanup.

Removing the unnecessary wrapper function simplifies the code.


180-202: LGTM! Methods follow existing patterns.

The new getter/setter methods for recommendedTokens and banners are consistent with existing methods and properly persist via syncToDb.


234-237: Hook exposure is complete.

All new methods are properly exposed in the useEarnActions hook.

Also applies to: 252-255

packages/kit/src/views/Earn/components/Overview.tsx (2)

258-265: Rebate fetch pattern looks good.

The conditional fetch based on evmAccount availability is appropriate.


310-316: LGTM! Refresh logic coordinates multiple updates.

The refresh properly clears cache, triggers state updates, and emits the portfolio refresh event.

Comment on lines +1097 to +1133
async fetchInvestmentDetailV2(params: {
publicKey?: string | undefined;
vault?: string | undefined;
accountAddress: string;
networkId: string;
provider: string;
symbol: string;
}) {
const client = await this.getClient(EServiceEndpointEnum.Earn);

const response = await client.get<{ data: IEarnInvestmentItemV2 }>(
`/earn/v2/investment/detail`,
{ params },
);

return response.data.data;
}

@backgroundMethod()
async fetchAirdropInvestmentDetail(params: {
publicKey?: string | undefined;
vault?: string | undefined;
accountAddress: string;
networkId: string;
provider: string;
symbol: string;
}) {
const client = await this.getClient(EServiceEndpointEnum.Earn);

const response = await client.get<{ data: IEarnAirdropInvestmentItemV2 }>(
`/earn/v1/investment/airdrop-detail`,
{ params },
);

return response.data.data;
}

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

Lowercase provider before calling the v2 detail APIs.

Both fetchInvestmentDetailV2 and fetchAirdropInvestmentDetail forward the provider string without normalization. A mixed-case provider (for example "Babylon") slips through here, the backend lookup fails, and the returned V2 payload is empty. Please mirror the normalization already used in getManagePage / getApyHistory and force the provider to lowercase before issuing these requests.

Apply this diff:

   @backgroundMethod()
   async fetchInvestmentDetailV2(params: {
     publicKey?: string | undefined;
     vault?: string | undefined;
     accountAddress: string;
     networkId: string;
     provider: string;
     symbol: string;
   }) {
     const client = await this.getClient(EServiceEndpointEnum.Earn);

-    const response = await client.get<{ data: IEarnInvestmentItemV2 }>(
-      `/earn/v2/investment/detail`,
-      { params },
-    );
+    const requestParams = {
+      ...params,
+      provider: params.provider.toLowerCase(),
+    };
+
+    const response = await client.get<{ data: IEarnInvestmentItemV2 }>(
+      `/earn/v2/investment/detail`,
+      { params: requestParams },
+    );

     return response.data.data;
   }

   @backgroundMethod()
   async fetchAirdropInvestmentDetail(params: {
     publicKey?: string | undefined;
     vault?: string | undefined;
     accountAddress: string;
     networkId: string;
     provider: string;
     symbol: string;
   }) {
     const client = await this.getClient(EServiceEndpointEnum.Earn);

-    const response = await client.get<{ data: IEarnAirdropInvestmentItemV2 }>(
-      `/earn/v1/investment/airdrop-detail`,
-      { params },
-    );
+    const response = await client.get<{ data: IEarnAirdropInvestmentItemV2 }>(
+      `/earn/v1/investment/airdrop-detail`,
+      {
+        params: {
+          ...params,
+          provider: params.provider.toLowerCase(),
+        },
+      },
+    );

     return response.data.data;
   }
🤖 Prompt for AI Agents
In packages/kit-bg/src/services/ServiceStaking.ts around lines 1097 to 1133,
both fetchInvestmentDetailV2 and fetchAirdropInvestmentDetail pass the provider
string as-is; normalize it to lowercase before calling the backend to avoid
lookup failures for mixed-case provider names. Update each method to copy/modify
the params (or set params.provider) to params.provider =
params.provider?.toLowerCase() (or construct a new paramsLower = { ...params,
provider: params.provider?.toLowerCase() }) and then pass that normalized params
object to client.get.

Comment on lines +12 to +26
const refetchBanners = useCallback(async () => {
const bannerResult =
await backgroundApiProxy.serviceStaking.fetchEarnHomePageBannerList();
const transformedBanners =
bannerResult?.map((i) => ({
...i,
imgUrl: i.src,
title: i.title || '',
titleTextProps: {
size: '$headingMd',
},
})) || [];

actions.current.updateBanners(transformedBanners);
}, [actions]);
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider error handling for banner fetch.

Line 14 calls fetchEarnHomePageBannerList without a try-catch. If the fetch fails, bannerResult is undefined and transformedBanners becomes an empty array (line 23). This silent failure might confuse users during network issues.

Add error handling or logging:

  const refetchBanners = useCallback(async () => {
+   try {
      const bannerResult =
        await backgroundApiProxy.serviceStaking.fetchEarnHomePageBannerList();
      const transformedBanners =
        bannerResult?.map((i) => ({
          ...i,
          imgUrl: i.src,
          title: i.title || '',
          titleTextProps: {
            size: '$headingMd',
          },
        })) || [];

      actions.current.updateBanners(transformedBanners);
+   } catch (error) {
+     console.error('Failed to fetch banners:', error);
+     actions.current.updateBanners([]);
+   }
  }, [actions]);
📝 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 refetchBanners = useCallback(async () => {
const bannerResult =
await backgroundApiProxy.serviceStaking.fetchEarnHomePageBannerList();
const transformedBanners =
bannerResult?.map((i) => ({
...i,
imgUrl: i.src,
title: i.title || '',
titleTextProps: {
size: '$headingMd',
},
})) || [];
actions.current.updateBanners(transformedBanners);
}, [actions]);
const refetchBanners = useCallback(async () => {
try {
const bannerResult =
await backgroundApiProxy.serviceStaking.fetchEarnHomePageBannerList();
const transformedBanners =
bannerResult?.map((i) => ({
...i,
imgUrl: i.src,
title: i.title || '',
titleTextProps: {
size: '$headingMd',
},
})) || [];
actions.current.updateBanners(transformedBanners);
} catch (error) {
console.error('Failed to fetch banners:', error);
actions.current.updateBanners([]);
}
}, [actions]);
🤖 Prompt for AI Agents
In packages/kit/src/views/Earn/hooks/useBannerInfo.ts around lines 12 to 26, the
call to fetchEarnHomePageBannerList lacks error handling so network or API
failures are swallowed and only an empty banner list is used; wrap the await
call in a try-catch, log or report the caught error (using backgroundApiProxy
logger or process logger) and optionally set a fallback state or keep previous
banners when the fetch fails, then only transform and update banners on
successful response inside the try block.

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

♻️ Duplicate comments (2)
packages/kit/src/views/Earn/hooks/useEarnPortfolio.ts (2)

53-67: Remove unused function (already flagged).

This function is still dead code. The filtering logic is duplicated inline at lines 362-372.


406-410: Stale investments persist after cashing out.

You add each key to fetchedKeys before the fetch completes. When fetchInvestmentDetail returns null (zero balance), the key stays in fetchedKeys but never enters batchMap. At lines 467-471, you only delete keys NOT in fetchedKeys, so the old investment remains in finalMap. Users still see zeroed positions.

Move fetchedKeys.add(key) into processResult after confirming the result is valid.

Apply this diff:

 const fetchPromises = pairsWithType.map(({ params, isAirdrop }) => {
-  const key = createInvestmentKey(params);
-  fetchedKeys.add(key);
   return fetchInvestmentDetail(params, isAirdrop, requestId);
 });

 // Process results incrementally
 const processResult = (
   result: Awaited<ReturnType<typeof fetchInvestmentDetail>>,
 ) => {
   if (!result || isRequestStale(requestId)) return;

   const { key, investment } = result;
+  fetchedKeys.add(key);

   // Merge with existing data for the same key
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 23f70b6 and 49dfcb3.

📒 Files selected for processing (1)
  • packages/kit/src/views/Earn/hooks/useEarnPortfolio.ts (1 hunks)
⏰ 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). (3)
  • GitHub Check: unittest (20.x)
  • GitHub Check: lint (20.x)
  • GitHub Check: Analyze (javascript-typescript)

Comment on lines +136 to +140
const firstAsset = inv.assets[0] || inv.airdropAssets[0];
return [
createInvestmentKey({
provider: inv.protocol.providerDetail.code,
symbol: firstAsset?.token.info.symbol || '',
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider defensive check for empty assets.

If both assets and airdropAssets are empty, firstAsset is undefined and the key gets an empty symbol. While optional chaining protects against crashes, an empty symbol could cause key collisions. Investments with positive fiat value should always have assets, but a defensive check or assertion would make this assumption explicit.

🤖 Prompt for AI Agents
In packages/kit/src/views/Earn/hooks/useEarnPortfolio.ts around lines 136-140,
the code picks firstAsset from inv.assets or inv.airdropAssets and then uses
firstAsset?.token.info.symbol which can be empty and cause key collisions; add a
defensive check: if neither assets nor airdropAssets has an entry, either
assert/throw (for investments that should always have assets) or use a stable
fallback (e.g. inv.id or a combination of provider+inv.id) as the symbol when
building the key, and log a warning so collisions are avoided and the assumption
is explicit.

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: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
packages/kit/src/views/Market/components/Chart/ChartViewAdapter.tsx (1)

23-40: Fix the contradictory eslint-disable pattern.

Your comment disables the rule but still provides a dependency array—these conflict. The current setup also creates a real problem: if the parent passes a different height or updates the onHover callback, the chart won't react to those changes.

Choose one approach:

  1. Complete the deps array: Remove the disable comment and add onHover and height to dependencies so the chart recreates when they change.
  2. Justify the exclusion: If intentional (e.g., lazy callback binding), remove the deps array entirely and replace the disable comment with a clear explanation of why these props are intentionally excluded.

Current code leaves it ambiguous whether this is deliberate or an oversight.

packages/kit/src/views/Staking/components/UniversalWithdraw/index.tsx (1)

235-276: Ensure transaction confirmation refetches when disabled state changes

fetchTransactionConfirmation now short‑circuits when isDisabled is true, which is good. But the useEffect that invokes debouncedFetchTransactionConfirmation only depends on amountValue and the debounced function. If isDisabled flips from true to false while the amount stays the same, you never refetch a confirmation for the current amount.

If isDisabled can change during the component’s lifetime (e.g., after some async gating check), consider adding it as a dependency so you refetch when enabling:

-  useEffect(() => {
-    void debouncedFetchTransactionConfirmation(amountValue);
-  }, [amountValue, debouncedFetchTransactionConfirmation]);
+  useEffect(() => {
+    void debouncedFetchTransactionConfirmation(amountValue);
+  }, [amountValue, isDisabled, debouncedFetchTransactionConfirmation]);

This keeps transactionConfirmation in sync with the actual enabled/disabled state.

packages/kit/src/views/Staking/components/UniversalStake/index.tsx (2)

906-960: Add stable keys for accordionContent items

accordionContent returns an array of React elements built via items.push(...), but none of the pushed elements have a key prop. When more than one item renders, React will log “each child in a list should have a unique key”.

You can fix this by adding explicit keys when pushing:

    if (transactionConfirmation?.receive) {
-      items.push(
-        <CalculationListItem>
+      items.push(
+        <CalculationListItem key="receive">
           ...
        </CalculationListItem>,
      );
    }
    if (estimateFeeResp) {
-      items.push(
-        <EstimateNetworkFee
+      items.push(
+        <EstimateNetworkFee
+          key="estimate-fee"
           isVisible
           estimateFeeResp={estimateFeeResp}
           onPress={() => {
             ...
           }}
        />,
      );
    }
    if (
      providerName?.toLowerCase() === EEarnProviderEnum.Babylon.toLowerCase() &&
      estimateFeeUTXO
    ) {
-      items.push(
-        <BtcFeeRateInput
+      items.push(
+        <BtcFeeRateInput
+          key="btc-fee-rate"
           estimateFeeUTXO={estimateFeeUTXO}
           onFeeRateChange={onFeeRateChange}
        />,
      );
    }

This keeps reconciliation predictable and removes noisy warnings.


544-575: Fold isDisabled into isDisable and gate mobile controls

Verification confirms the gaps. The isDisable memo (line 548) lacks isDisabled in both its logic and dependencies. This means the confirm button can remain enabled even when the product is disabled. On mobile, PercentageStageOnKeyboard (line 1313) has no isDisabled check, so users can still adjust amounts via the keyboard when the product is disabled.

Apply both fixes:

  1. Add isDisabled to isDisable conditions and dependencies (lines 548–570)
  2. Gate the mobile footer with && !isDisabled (line 1310)

This prevents the staking flow from accepting user input when the product is disabled.

♻️ Duplicate comments (14)
packages/kit/src/views/Earn/components/PortfolioTabContent.tsx (1)

698-712: Move the key to the fragment wrapper.

The key prop should be on the wrapping fragment, not on PortfolioItem. React needs the key on the direct child of the map. Replace the shorthand fragment with React.Fragment.

Apply this diff:

       {investments.map((item, index) => {
         const showDivider = index < investments.length - 1;
         const key = `${item.protocol.providerDetail.code}_${
           item.protocol.vaultName || ''
         }_${item.network.networkId}`;

         return (
-          <>
-            <PortfolioItem key={key} portfolioItem={item} />
+          <React.Fragment key={key}>
+            <PortfolioItem portfolioItem={item} />
             {showDivider ? <Divider my="$4" /> : null}
-          </>
+          </React.Fragment>
         );
       })}
packages/kit/src/views/Earn/hooks/useEarnPortfolio.ts (2)

118-131: Consider defensive check for empty assets in key generation.

Line 120 assumes investments with positive fiat value always have at least one asset. While this should be true, an explicit check or assertion would make the invariant clear and prevent potential key collisions from empty symbols.

Example:

validInvestments.map((inv) => {
  const firstAsset = inv.assets[0] || inv.airdropAssets[0];
  if (!firstAsset) {
    console.warn('Investment has positive fiat value but no assets', inv);
    return null; // or throw
  }
  return [
    createInvestmentKey({
      provider: inv.protocol.providerDetail.code,
      symbol: firstAsset.token.info.symbol,
      vault: inv.protocol.vault,
      networkId: inv.network.networkId,
    }),
    inv,
  ];
}).filter(Boolean)

390-394: Fix key tracking to allow zeroed positions to drop.

Lines 390-394 add each key to fetchedKeys before the fetch completes. If fetchInvestmentDetail returns null (e.g., for zero balances), the key remains in fetchedKeys, so the final sweep at lines 450-456 won't remove it. Users will see stale data after cashing out.

Only add a key to fetchedKeys after a successful fetch returns data.

Apply this diff:

       // Create fetch promises
       const fetchPromises = pairsWithType.map(({ params, isAirdrop }) => {
-        const key = createInvestmentKey(params);
-        fetchedKeys.add(key);
-        return fetchInvestmentDetail(params, isAirdrop, requestId);
+        return fetchInvestmentDetail(params, isAirdrop, requestId);
       });

       // Process results incrementally
       const processResult = (
         result: Awaited<ReturnType<typeof fetchInvestmentDetail>>,
       ) => {
         if (!result || isRequestStale(requestId)) return;

         const { key, investment } = result;
+        fetchedKeys.add(key);

         // Merge with existing data for the same key
packages/kit/src/views/Earn/pages/EarnProtocolDetails/components/ApyChart.tsx (1)

78-102: Fix hover handling to support zero values.

The truthy checks on time and price exclude legitimate zero values. Users lose hover feedback when APY is 0%. Check for undefined instead.

  const handleHover = useCallback(
    ({
      time,
      price,
      x,
      y,
    }: {
      time?: number;
      price?: number;
      x?: number;
      y?: number;
    }) => {
-     if (time && price && x !== undefined && y !== undefined) {
+     if (time !== undefined && price !== undefined && x !== undefined && y !== undefined) {
        setHoverData({
          time,
          apy: price,
          x,
          y,
        });
      } else {
        setHoverData(null);
      }
    },
    [],
  );
packages/kit/src/views/Earn/pages/EarnProtocols/index.tsx (3)

81-108: Log fetch errors for debugging.

The catch block silently swallows errors. Add logging so failures are recorded.

    } catch (error) {
+     defaultLogger.staking.page.error('Failed to fetch protocol data:', error);
      setProtocolData([]);
    } finally {

114-157: Log navigation errors for debugging.

The catch block silently swallows navigation errors. Add logging to help debug failed attempts.

      });
    } catch (error) {
+     defaultLogger.staking.page.error('Failed to navigate to protocol details:', error);
      // Handle error silently
    }

230-242: Enable APR sorting before shipping.

The yield column lacks sortable metadata and a comparator. The default sort at line 298 won't work. Add the flag and comparator.

      {
        key: 'yield',
        label: intl.formatMessage({ id: ETranslations.global_apr }),
        flex: 1,
+       sortable: true,
+       comparator: (a, b) => {
+         const aprA = parseFloat(
+           a?.provider?.aprWithoutFee ??
+             a?.aprInfo?.value ??
+             a?.aprInfo?.apr ??
+             '0',
+         );
+         const aprB = parseFloat(
+           b?.provider?.aprWithoutFee ??
+             b?.aprInfo?.value ??
+             b?.aprInfo?.apr ??
+             '0',
+         );
+         return aprA - aprB;
+       },
        render: (item) => (
          <AprText
packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx (5)

369-383: Fix countdown visibility window.

The alert only renders after the end time passes. Flip the comparison so it shows while time is between start and end.

            {detailInfo?.countDownAlert?.startTime &&
            detailInfo?.countDownAlert?.endTime &&
            now > detailInfo.countDownAlert.startTime &&
-           detailInfo.countDownAlert.endTime < now ? (
+           detailInfo.countDownAlert.endTime > now ? (
              <YStack pb="$1">

565-571: Remove incorrect await.

Line 567 awaits onCreateAddress() but it returns void, not a Promise. Remove the await to fix the linter error.

  const handleCreateAddress = useCallback(async () => {
    if (onCreateAddress) {
-     await onCreateAddress();
+     onCreateAddress();
    }
    await refreshManageAccount();
    await refreshManageData();
  }, [onCreateAddress, refreshManageAccount, refreshManageData]);

145-171: Use a more unique key.

Using cell.title.text assumes titles are unique. If they repeat, React will misbehave. Use an index or unique ID.

-           {intro.items.map((cell) => (
+           {intro.items.map((cell, idx) => (
              <GridItem
-               key={cell.title.text}
+               key={`${cell.title.text}-${idx}`}

267-314: Move key to Fragment.

When mapping returns a Fragment, put the key on the Fragment itself, not its children. This prevents reconciliation issues.

          {risk.items?.map((item) => (
-           <>
-             <XStack ai="center" gap="$3" key={item.title.text}>
+           <Fragment key={item.title.text}>
+             <XStack ai="center" gap="$3">
              ...
-           </>
+           </Fragment>

825-843: Fix inconsistent indexedAccountId usage.

Line 825 destructures indexedAccountId from resolved params, but line 839 passes indexedAccount?.id from the hook. This creates inconsistency—share-link navigation might use wrong account context.

  const { accountId, networkId, indexedAccountId, symbol, provider, vault } =
    resolvedParams;

  const {
    earnAccount,
    detailInfo,
    tokenInfo,
    protocolInfo,
    isLoading,
    refreshData,
    refreshAccount,
  } = useProtocolDetailData({
    accountId,
    networkId,
-   indexedAccountId: indexedAccount?.id,
+   indexedAccountId,
    symbol,
    provider,
    vault,
  });
packages/kit/src/views/Staking/components/UniversalWithdraw/index.tsx (1)

498-583: Fix crash when rewards are missing

transactionConfirmation?.rewards.map((reward) => { ... }) still calls .map on undefined when transactionConfirmation is unset or when it has no rewards, which will crash the component (especially on first render while data loads).

Guard rewards before mapping, for example:

-            {transactionConfirmation?.rewards.map((reward) => {
+            {transactionConfirmation?.rewards?.map((reward) => {

This matches the earlier review note and makes the rewards section safe in all loading/error states.

packages/kit/src/views/Staking/components/UniversalStake/index.tsx (1)

1168-1212: Guard .rewards.map to avoid runtime crash when rewards is undefined

transactionConfirmation?.rewards.map((reward) => { ... }) still assumes rewards is always a defined array.

If transactionConfirmation exists but rewards is undefined (which can happen while the confirmation payload is still shaping up), this will throw Cannot read properties of undefined (reading 'map') and crash the component.

You already use optional chaining on transactionConfirmation, so the minimal fix is to also guard rewards:

-            {transactionConfirmation?.rewards.map((reward) => {
+            {transactionConfirmation?.rewards?.map((reward) => {

You could also choose a more explicit guard (if (!transactionConfirmation?.rewards?.length) return null;) if you want to avoid rendering the whole rewards block until the list is present.

This is the same issue called out in an earlier review; it still needs a fix.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 49dfcb3 and df3c6e4.

📒 Files selected for processing (20)
  • packages/kit/src/components/LightweightChart/LightweightChart.native.tsx (1 hunks)
  • packages/kit/src/components/LightweightChart/LightweightChart.tsx (1 hunks)
  • packages/kit/src/components/LightweightChart/hooks/useChartConfig.ts (1 hunks)
  • packages/kit/src/components/LightweightChart/index.ts (1 hunks)
  • packages/kit/src/components/LightweightChart/types/index.ts (1 hunks)
  • packages/kit/src/components/LightweightChart/utils/chartOptions.ts (1 hunks)
  • packages/kit/src/components/LightweightChart/utils/constants.ts (1 hunks)
  • packages/kit/src/components/LightweightChart/utils/htmlTemplate.ts (1 hunks)
  • packages/kit/src/views/Earn/components/EarnPageContainer.tsx (1 hunks)
  • packages/kit/src/views/Earn/components/PortfolioTabContent.tsx (1 hunks)
  • packages/kit/src/views/Earn/hooks/useEarnPortfolio.ts (1 hunks)
  • packages/kit/src/views/Earn/pages/EarnProtocolDetails/components/ApyChart.tsx (1 hunks)
  • packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx (1 hunks)
  • packages/kit/src/views/Earn/pages/EarnProtocols/index.tsx (1 hunks)
  • packages/kit/src/views/Market/components/Chart/ChartViewAdapter.tsx (3 hunks)
  • packages/kit/src/views/Market/components/Chart/chartUtils.ts (2 hunks)
  • packages/kit/src/views/Staking/components/StakingFormWrapper/index.tsx (1 hunks)
  • packages/kit/src/views/Staking/components/UniversalStake/index.tsx (4 hunks)
  • packages/kit/src/views/Staking/components/UniversalWithdraw/index.tsx (6 hunks)
  • packages/kit/src/views/Staking/pages/ManagePosition/components/HeaderRight.tsx (1 hunks)
⏰ 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: Analyze (javascript-typescript)
  • GitHub Check: Analyze (java-kotlin)
🔇 Additional comments (22)
packages/kit/src/views/Staking/components/StakingFormWrapper/index.tsx (1)

8-8: LGTM! Spacing increase improves layout.

The increased gap enhances visual hierarchy and breathing room in the staking form. Clean change aligned with the redesign goals.

packages/kit/src/views/Market/components/Chart/chartUtils.ts (2)

79-91: Time scale is now visible—confirm this is intentional.

You've enabled the time scale with custom MM/DD formatting. This changes the chart's appearance. Verify this matches the design spec.


86-91: Date formatter looks clean.

The MM/DD formatter is straightforward and handles padding correctly.

packages/kit/src/views/Earn/components/PortfolioTabContent.tsx (4)

45-115: LGTM: WrappedActionButton handles action dispatch cleanly.

The component correctly determines the symbol for config lookup, handles airdrop vs normal claims, and passes all necessary data to handleAction. Type guards for optional properties are appropriate.


117-147: LGTM: DepositField renders deposit info correctly.

Clean presentational component with appropriate conditional rendering for vaultName.


178-199: LGTM: ActionField correctly keys the mapped elements.


584-627: LGTM: Skeleton components provide appropriate loading state.

packages/kit/src/views/Earn/hooks/useEarnPortfolio.ts (4)

24-96: LGTM: Utility functions are well-structured and pure.

The helper functions for creating keys, filtering by fiat value, sorting, calculating totals, and aggregating by protocol are clear and correct. Using BigNumber for currency calculations is appropriate.


149-190: LGTM: useAccountState correctly manages account change detection and request staleness.

The ref-based tracking of account changes and request IDs prevents stale data from updating the UI.


308-473: LGTM: RAF batching and incremental updates are well-implemented.

The request animation frame throttling, staleness checks, and partial vs. full refresh logic are solid. The only issue is the fetchedKeys tracking (noted separately).


475-503: LGTM: Hook integration and memoization are correct.

The usePromiseResult integration, refresh wrapper, and aggregatedInvestments memo are properly structured. The eslint disable for investmentMapRef is justified since refs don't trigger re-renders.

packages/kit/src/components/LightweightChart/LightweightChart.native.tsx (2)

36-59: LGTM: Message handling is robust.

The try-catch block properly handles JSON parsing errors, and the type guard for 'ready' and 'hover' messages is clear.


81-95: Good security posture for WebView.

Lines 91-94 properly restrict WebView capabilities: allowFileAccess={false}, allowUniversalAccessFromFileURLs={false}, and mixedContentMode="never" minimize security risks.

packages/kit/src/components/LightweightChart/index.ts (1)

1-3: LGTM: Clean barrel exports.

packages/kit/src/components/LightweightChart/utils/constants.ts (2)

6-10: LGTM: Default colors defined.


1-4: Version is correctly aligned. No action needed.

The constant 3.8.0 matches the ^3.8.0 range in both package.json files. Since CDN URLs require exact versions, using a hardcoded constant is the right approach. Importing directly from package.json wouldn't work—package.json stores the range ^3.8.0, not the exact version.

packages/kit/src/components/LightweightChart/LightweightChart.tsx (1)

86-96: LGTM: ResizeObserver properly handles chart resizing.

packages/kit/src/components/LightweightChart/hooks/useChartConfig.ts (1)

18-47: LGTM: Hook correctly memoizes config.

The dependency array properly includes all inputs that affect the config, and default values are sensibly chosen from constants. Theme fallbacks ensure the chart always has valid colors.

packages/kit/src/views/Earn/components/EarnPageContainer.tsx (1)

48-66: LGTM: Proper memoization of callbacks and computed values.

handleBack and customHeaderLeft are correctly memoized with appropriate dependencies.

packages/kit/src/components/LightweightChart/utils/htmlTemplate.ts (1)

132-145: LGTM!

Clean HTML template generation with proper structure and documentation. The function correctly embeds config and wires up the chart library.

packages/kit/src/views/Earn/pages/EarnProtocolDetails/components/ApyChart.tsx (1)

117-157: LGTM!

Data fetching and transformation logic is solid. Correctly handles empty results, calculates high/low values, and converts timestamps for the chart.

packages/kit/src/views/Staking/components/UniversalStake/index.tsx (1)

230-256: Guarding transaction confirmation requests when disabled looks good

Short‑circuiting fetchTransactionConfirmation when isDisabled is true avoids pointless API calls and state updates while the product is disabled. Including isDisabled in the hook dependency list also keeps the debounced flow consistent with the new flag. No issues here from my side.

Comment on lines +62 to +76
useEffect(() => {
if (webViewReady && webViewRef.current) {
const updateScript = `
(function() {
const newConfig = ${JSON.stringify(chartConfig)};
if (window.series) {
window.series.setData(newConfig.data);
window.chart.timeScale().fitContent();
}
})();
true;
`;
webViewRef.current.injectJavaScript(updateScript);
}
}, [chartConfig, webViewReady]);
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

Check serialized config size and injection performance.

Line 66 serializes the entire chartConfig with JSON.stringify on every data update. For large datasets, this could impact performance. Additionally, no error handling exists if injectJavaScript fails.

Consider:

  1. Measuring the typical size of chartConfig.data arrays
  2. Adding error handling for injection failures
  3. Potentially debouncing updates if data changes rapidly

🏁 Script executed:

#!/bin/bash
# Check typical usage patterns of LightweightChart to assess data size
rg -A 5 -B 2 '<LightweightChart' --type=tsx --type=ts

Length of output: 27



---

🏁 Script executed:

```shell
#!/bin/bash
# Find LightweightChart usage with corrected syntax
rg -A 5 -B 2 '<LightweightChart' -g '*.ts' -g '*.tsx'

Length of output: 812


🏁 Script executed:

#!/bin/bash
# Look for error handling patterns around injectJavaScript
rg -n 'injectJavaScript' -C 4 -g '*.ts' -g '*.tsx'

Length of output: 7246


🏁 Script executed:

#!/bin/bash
# Check if debounce or throttle is used anywhere in the component file
rg -n 'debounce|throttle|memo|useMemo|useCallback' packages/kit/src/components/LightweightChart/LightweightChart.native.tsx

Length of output: 150


🏁 Script executed:

#!/bin/bash
# Examine the full LightweightChart.native.tsx file around the useEffect
cat packages/kit/src/components/LightweightChart/LightweightChart.native.tsx | head -100

Length of output: 2758


Add error handling for injectJavaScript call.

Line 74 lacks a try-catch wrapper. Other injectJavaScript calls in the codebase (explorerUtils.ts, Browser.native.tsx, discovery/actions.ts) wrap this in try-catch because the call can fail—especially on mobile orientation changes—and crash the app. Apply the same pattern here.

For JSON serialization and update frequency: the component already uses useMemo for the HTML content. Monitor actual usage patterns in production before optimizing further, but error handling is mandatory now.

🤖 Prompt for AI Agents
In packages/kit/src/components/LightweightChart/LightweightChart.native.tsx
around lines 62 to 76, the webViewRef.current.injectJavaScript call is not
wrapped in error handling and can throw (e.g. on orientation changes); wrap the
injectJavaScript invocation in a try-catch, catch any thrown error, and handle
it consistently with other parts of the codebase (log the error via the existing
logger or console.error with context) so the app does not crash; ensure the try
block only contains the injectJavaScript call and keep the existing logic
otherwise unchanged.

Comment on lines +35 to +102
useEffect(() => {
if (!chartContainerRef.current) return;

const chart = createChart(chartContainerRef.current, {
...createChartOptions(chartConfig.theme),
width: chartContainerRef.current.clientWidth,
height,
});

const series = chart.addAreaSeries(
createAreaSeriesOptions(chartConfig.theme),
);
series.setData(chartConfig.data as any);
chart.timeScale().fitContent();

chartRef.current = chart;
seriesRef.current = series;

// Subscribe to crosshair move events
if (onHover) {
chart.subscribeCrosshairMove((param) => {
if (
param.time &&
param.seriesPrices &&
param.seriesPrices.size > 0 &&
param.point
) {
const price = param.seriesPrices.get(series);
if (price !== undefined) {
onHover({
time:
typeof param.time === 'number'
? param.time
: Number(param.time),
price: typeof price === 'number' ? price : Number(price),
x: param.point.x,
y: param.point.y,
});
}
} else {
onHover({
time: undefined,
price: undefined,
x: undefined,
y: undefined,
});
}
});
}

// Handle resize
const resizeObserver = new ResizeObserver((entries) => {
if (
entries.length === 0 ||
entries[0].target !== chartContainerRef.current
)
return;
const { width: newWidth } = entries[0].contentRect;
chart.applyOptions({ width: newWidth });
});

resizeObserver.observe(chartContainerRef.current);

return () => {
resizeObserver.disconnect();
chart.remove();
};
}, [chartConfig, height, onHover]);
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Large useEffect could be split for clarity.

This useEffect handles chart creation, event subscription, and resize observation all in one. The dependency array on line 102 includes chartConfig, height, and onHover, which means the entire chart is recreated when any of these change.

Consider splitting into:

  1. Chart creation/destruction (depends on height)
  2. Data updates (depends on chartConfig)
  3. Hover handler updates (depends on onHover)

This would avoid recreating the chart on every data change.

🤖 Prompt for AI Agents
In packages/kit/src/components/LightweightChart/LightweightChart.tsx around
lines 35 to 102, the single large useEffect is creating/removing the chart,
subscribing to crosshair events, and observing resizes so any change to
chartConfig or onHover recreates the whole chart; refactor into three smaller
effects: 1) a chart creation/destruction effect that depends only on height
(creates chart, adds series, sets initial options, starts ResizeObserver, and
cleans up chart and observer), 2) a data update effect that depends on
chartRef.current/seriesRef.current and chartConfig.data (calls series.setData
and chart.timeScale().fitContent without recreating the chart), and 3) a hover
subscription effect that depends only on onHover and the current series
(subscribeCrosshairMove on mount or when onHover changes and unsubscribe on
cleanup, sending undefined coordinates when no hit); ensure you reference
chartRef.current/seriesRef.current safely and tighten dependency arrays so data
or hover updates do not recreate the chart.

Comment on lines +44 to +48
const series = chart.addAreaSeries(
createAreaSeriesOptions(chartConfig.theme),
);
series.setData(chartConfig.data as any);
chart.timeScale().fitContent();
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

Type assertion suggests API mismatch.

Line 47 uses as any when calling series.setData(chartConfig.data as any). This indicates a type incompatibility between ILightweightChartData[] and what the library expects.

Verify the data format matches lightweight-charts v3.8.0 requirements:


🌐 Web query:

What is the expected data format for setData in lightweight-charts 3.8.0 area series?

💡 Result:

For an area series in lightweight-charts v3.8.0, setData expects an array of AreaData items: objects with a time (Time) and a numeric value, optionally including per-point colors (lineColor, topColor, bottomColor). [1][2]

References:

  • AreaData interface (time, value, lineColor, topColor, bottomColor). [1]
  • Type definitions showing AreaData / SingleValueData shape. [2]

🏁 Script executed:

#!/bin/bash
# Search for ILightweightChartData definition and chartConfig usage in the repo
echo "=== Searching for ILightweightChartData type definition ==="
rg -n "ILightweightChartData" --type=ts --type=tsx -B3 -A5

echo ""
echo "=== Searching for chartConfig.data reference ==="
rg -n "chartConfig\\.data" --type=ts --type=tsx -B2 -A2

echo ""
echo "=== Checking LightweightChart.tsx for interface definitions ==="
fd "LightweightChart\\.tsx" --type=f -x cat {}

Length of output: 2966



🏁 Script executed:

#!/bin/bash
# Find and display types definition file
echo "=== Finding types file ==="
fd "types\\.ts" "packages/kit/src/components/LightweightChart/" --type=f -x cat {}

echo ""
echo "=== Finding useChartConfig hook ==="
fd "useChartConfig" "packages/kit/src/components/LightweightChart/" --type=f -x cat {}

echo ""
echo "=== Searching for AreaData or lightweight-charts type imports ==="
rg "AreaData|from.*lightweight-charts" "packages/kit/src/components/LightweightChart/" -n -A2

Length of output: 2158


🏁 Script executed:

#!/bin/bash
# Find ILightweightChartConfig definition
echo "=== Searching for ILightweightChartConfig ==="
rg "interface ILightweightChartConfig|type ILightweightChartConfig" "packages/kit/src/components/LightweightChart/" -A10

echo ""
echo "=== Looking for types directory ==="
fd "types" "packages/kit/src/components/LightweightChart/" --type d

echo ""
echo "=== Check all TypeScript files in LightweightChart ==="
fd "\\.ts$" "packages/kit/src/components/LightweightChart/" --type f

Length of output: 1497


🏁 Script executed:

#!/bin/bash
# Display complete types/index.ts file
echo "=== Complete types/index.ts ==="
cat "packages/kit/src/components/LightweightChart/types/index.ts"

Length of output: 858


Remove the unnecessary type assertion.

The as any on line 47 is unwarranted. ILightweightChartData defines objects with time: number and value: number, which perfectly matches what lightweight-charts v3.8.0 AreaData expects: objects with a time (accepts numbers) and numeric value. The data transformation in useChartConfig already produces the correct shape. Remove as any to restore type safety.

series.setData(chartConfig.data);
🤖 Prompt for AI Agents
In packages/kit/src/components/LightweightChart/LightweightChart.tsx around
lines 44 to 48, remove the unnecessary unsafe type assertion on series.setData:
the chartConfig.data already matches the AreaData shape (time:number,
value:number) so drop "as any" and call series.setData(chartConfig.data) to
restore type safety; ensure imports/types remain compatible with
lightweight-charts v3.8.0 and adjust any surrounding code or types if the
compiler complains.

Comment on lines +36 to +42
export interface IChartMessage {
type: 'ready' | 'hover';
time?: string;
price?: string;
x?: number;
y?: number;
}
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

Type mismatch: IChartMessage uses strings but onHover expects numbers.

Lines 38-39 define time and price as optional strings in IChartMessage, but lines 29-30 in ILightweightChartProps.onHover expect optional numbers. This inconsistency requires runtime conversion and could introduce bugs.

Consider aligning the types. If the message format requires strings for serialization, update the interface to reflect that clearly:

 export interface IChartMessage {
   type: 'ready' | 'hover';
-  time?: string;
-  price?: string;
+  time?: number;
+  price?: number;
   x?: number;
   y?: number;
 }

Or add JSDoc comments explaining the conversion requirement if strings are mandatory for the WebView bridge.

📝 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 interface IChartMessage {
type: 'ready' | 'hover';
time?: string;
price?: string;
x?: number;
y?: number;
}
export interface IChartMessage {
type: 'ready' | 'hover';
time?: number;
price?: number;
x?: number;
y?: number;
}
🤖 Prompt for AI Agents
In packages/kit/src/components/LightweightChart/types/index.ts around lines 36
to 42, IChartMessage declares time and price as optional strings while
ILightweightChartProps.onHover expects numbers; align these types to avoid
runtime conversion bugs: either change IChartMessage.time and
IChartMessage.price to optional numbers to match onHover (preferred), or if the
WebView bridge requires string serialization, change the onHover parameter types
to optional strings and add JSDoc explaining the serialization/parse contract
and where conversion happens; ensure only one canonical type is used across the
message definition and handler.

Comment on lines +20 to +26
crosshair: {
mode: 1, // CrosshairMode.Normal
vertLine: {
color: theme.lineColor,
width: 1,
style: 3,
labelVisible: false,
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 magic number with enum.

Line 21 uses mode: 1 with a comment // CrosshairMode.Normal. Import and use the actual enum instead.

+import { CrosshairMode } from 'lightweight-charts';
+
 export function createChartOptions(
   theme: ILightweightChartTheme,
 ): DeepPartial<ChartOptions> {
   return {
     // ...
     crosshair: {
-      mode: 1, // CrosshairMode.Normal
+      mode: CrosshairMode.Normal,
📝 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
crosshair: {
mode: 1, // CrosshairMode.Normal
vertLine: {
color: theme.lineColor,
width: 1,
style: 3,
labelVisible: false,
import { CrosshairMode } from 'lightweight-charts';
export function createChartOptions(
theme: ILightweightChartTheme,
): DeepPartial<ChartOptions> {
return {
// ...
crosshair: {
mode: CrosshairMode.Normal,
vertLine: {
color: theme.lineColor,
width: 1,
style: 3,
labelVisible: false,
🤖 Prompt for AI Agents
In packages/kit/src/components/LightweightChart/utils/chartOptions.ts around
lines 20 to 26, the crosshair.mode is set to the magic number `1` with a comment
indicating `CrosshairMode.Normal`; replace the literal with the actual enum
value by importing CrosshairMode from the lightweight-charts package (or the
project’s chart enums) and set mode: CrosshairMode.Normal instead of 1; add the
import at the top of the file and update the crosshair object to use the enum
constant.

const handleManagePress = useCallback(
async (asset: IEarnPortfolioInvestment['assets'][number]) => {
const symbol = asset.token.info.symbol;
if (symbol === 'USDe') {
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider externalizing the USDe symbol check.

The hardcoded check for symbol === 'USDe' creates a special navigation path. If this list grows, consider moving symbol-based routing logic to a config map or helper function for maintainability.

🤖 Prompt for AI Agents
In packages/kit/src/views/Earn/components/PortfolioTabContent.tsx around line
406, there's a hardcoded symbol check `if (symbol === 'USDe')` creating
special-case navigation; extract this logic into a centralized mapping or helper
(e.g., a symbolToRoute map or getRouteForSymbol(symbol) function) and replace
the inline comparison with a lookup call that returns the appropriate
route/action for the symbol (falling back to default behavior when not found).
Update imports and tests as needed and keep behavior identical for 'USDe' while
making it easy to add more symbol rules later.

Comment on lines +698 to +712
<YStack>
{investments.map((item, index) => {
const showDivider = index < investments.length - 1;
const key = `${item.protocol.providerDetail.code}_${
item.protocol.vaultName || ''
}_${item.network.networkId}`;

return (
<>
<PortfolioItem key={key} portfolioItem={item} />
{showDivider ? <Divider my="$4" /> : null}
</>
);
})}
</YStack>
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider virtualization for large portfolios.

If users accumulate many protocol positions, this non-virtualized list could impact scroll performance. Monitor and consider adding virtualization if portfolios commonly exceed 50 items.

🤖 Prompt for AI Agents
In packages/kit/src/views/Earn/components/PortfolioTabContent.tsx around lines
698 to 712, the current map renders all PortfolioItem components inline which
can hurt scroll performance for large portfolios; replace this non-virtualized
mapping with a virtualization solution (e.g., react-virtual, react-window,
react-virtuoso or a platform-appropriate virtual list) so only visible items are
rendered. Implement virtualization with a stable itemKey using the existing key
composition (protocol.providerDetail.code + vaultName + networkId), preserve
Divider rendering between items (render divider inside the row renderer for all
but the last visible item), supply an estimated item height and container
ref/scrollParent, and conditionally enable virtualization only when
investments.length exceeds a threshold (e.g., 50) to avoid unnecessary
complexity for small lists.

Comment on lines +140 to +145
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 4,
crosshairMarkerBorderColor: lineColor,
crosshairMarkerBackgroundColor: '#FFFFFF',
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

Hardcoded white breaks dark theme.

Line 145 uses '#FFFFFF' for the crosshair marker background. This won't work in dark mode. Pass a theme-aware color (like you did with textColor) or derive it from the lineColor parameter.

Apply this diff:

+  backgroundTextColor?: string,
 ) {
   ...
   const newSeries = chart.addAreaSeries({
     lineColor,
     topColor,
     bottomColor,
     lineWidth: 2,
     priceLineVisible: false,
     lastValueVisible: false,
     crosshairMarkerVisible: true,
     crosshairMarkerRadius: 4,
     crosshairMarkerBorderColor: lineColor,
-    crosshairMarkerBackgroundColor: '#FFFFFF',
+    crosshairMarkerBackgroundColor: backgroundTextColor ?? '#FFFFFF',
   });

Then pass the appropriate theme color from ChartViewAdapter.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/kit/src/views/Market/components/Chart/chartUtils.ts around lines 140
to 145 the crosshairMarkerBackgroundColor is hardcoded to '#FFFFFF' which breaks
dark theme; replace that hardcoded value with a theme-aware color (either accept
a new backgroundColor parameter, derive a contrasting background from the
existing lineColor, or use the same textColor logic used elsewhere) and update
callers to pass the theme-aware color; then update ChartViewAdapter to pass the
appropriate color from the active theme (e.g., a light background in light theme
and a dark background in dark theme) so the marker remains visible in both
themes.

historyAction?: IEarnHistoryActionIcon;
onHistory?: ((params?: { filterType?: string }) => void) | undefined;
}) => {
if (historyAction && !historyAction?.disabled) {
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Remove redundant optional chaining.

After the &&, historyAction is guaranteed truthy. The ?. on the right side does nothing.

Apply this diff:

-  if (historyAction && !historyAction?.disabled) {
+  if (historyAction && !historyAction.disabled) {
📝 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 (historyAction && !historyAction?.disabled) {
if (historyAction && !historyAction.disabled) {
🤖 Prompt for AI Agents
In
packages/kit/src/views/Staking/pages/ManagePosition/components/HeaderRight.tsx
around line 15, the conditional uses redundant optional chaining: "if
(historyAction && !historyAction?.disabled)". Since the left side ensures
historyAction is truthy, remove the "?." and change the check to "if
(historyAction && !historyAction.disabled)"; update any surrounding
code/comments if needed and run lint/tests.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/kit/src/views/Earn/EarnHome.tsx (1)

130-188: Handle malformed staking deeplinks more gracefully.

In onBannerPress, when href contains /defi/staking but required params (like networkId) are missing, the function hits the staking branch and then returns without any fallback. From a user view, tapping such a banner does nothing.

Consider falling back to openUrlInApp/openUrlExternal (or logging/monitoring) when deeplink parsing fails, instead of a silent no‑op.

♻️ Duplicate comments (9)
packages/shared/src/routes/staking.ts (1)

58-65: Unresolved: ManagePosition still missing accountId.

This is the same critical issue flagged in the previous review. The ManagePosition component requires accountId (used at lines 132, 151, 245, 254 in index.tsx), but the route definition omits it. The component falls back to useActiveAccount, which is inconsistent with ProtocolDetailsV2 that passes accountId explicitly.

Either add accountId and indexedAccountId to the params, or document why they're intentionally omitted (like ProtocolDetailsV2Share does on line 56).

packages/kit/src/views/Earn/pages/EarnProtocolDetails/components/ApyChart.tsx (1)

87-111: Fix hover handling when APY or timestamp equals zero.

The truthy checks on line 99 (if (time && price && x !== undefined && y !== undefined)) will reject legitimate zero values. A 0% APY or timestamp 0 won't update hover state.

Guard against undefined/null instead:

  const handleHover = useCallback(
    ({
      time,
      price,
      x,
      y,
    }: {
      time?: number;
      price?: number;
      x?: number;
      y?: number;
    }) => {
-      if (time && price && x !== undefined && y !== undefined) {
+      if (
+        time !== undefined &&
+        time !== null &&
+        price !== undefined &&
+        price !== null &&
+        x !== undefined &&
+        y !== undefined
+      ) {
        setHoverData({
          time,
          apy: price,
          x,
          y,
        });
      } else {
        setHoverData(null);
      }
    },
    [],
  );
packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx (4)

369-383: Fix countdown visibility window.

Line 372 checks endTime < now, which only renders the alert AFTER the countdown ends. Flip it to show the alert WHILE counting down (between startTime and endTime).

            {detailInfo?.countDownAlert?.startTime &&
            detailInfo?.countDownAlert?.endTime &&
            now > detailInfo.countDownAlert.startTime &&
-            detailInfo.countDownAlert.endTime < now ? (
+            detailInfo.countDownAlert.endTime > now ? (
              <YStack pb="$1">

145-171: Consider using a more unique key.

Line 147 uses key={cell.title.text}, which assumes titles are unique. If they're not, React will warn.

Use index or composite key:

-            {intro.items.map((cell) => (
+            {intro.items.map((cell, idx) => (
              <GridItem
-                key={cell.title.text}
+                key={`${cell.title.text}-${idx}`}

267-314: Move key to Fragment.

When mapping returns a Fragment, the key must be on the Fragment itself, not its children. This prevents React reconciliation issues.

          {risk.items?.map((item) => (
-            <>
-              <XStack ai="center" gap="$3" key={item.title.text}>
+            <Fragment key={item.title.text}>
+              <XStack ai="center" gap="$3">
                <YStack flex={1} gap="$2">
                  ...
                </YStack>
              </XStack>
              ...
-            </>
+            </Fragment>
          ))}

825-843: Fix inconsistent indexedAccountId usage.

Line 825 destructures indexedAccountId from resolvedParams, but line 839 passes indexedAccount?.id from the active account hook instead. This creates inconsistency—share-link navigation might use a different account than intended.

Use the resolved param consistently:

  const { accountId, networkId, indexedAccountId, symbol, provider, vault } =
    resolvedParams;

  const {
    earnAccount,
    detailInfo,
    tokenInfo,
    protocolInfo,
    isLoading,
    refreshData,
    refreshAccount,
  } = useProtocolDetailData({
    accountId,
    networkId,
-    indexedAccountId: indexedAccount?.id,
+    indexedAccountId,
    symbol,
    provider,
    vault,
  });
packages/kit/src/views/Staking/pages/ManagePosition/index.tsx (1)

89-117: Fix the incomplete type annotation (past review issue).

The type annotation still omits accountId and indexedAccountId, but they're returned on lines 108-110. This causes a TypeScript error. Add both fields to the type or remove the explicit generic.

Also, isFromShareLink is hardcoded to false (line 115). If it's never true, remove it from the type and return object.

Apply this diff:

-  const resolvedParams = useMemo<{
-    networkId: string;
-    symbol: ISupportedSymbol;
-    provider: string;
-    vault: string | undefined;
-    isFromShareLink: boolean;
-  }>(() => {
+  const resolvedParams = useMemo<{
+    accountId: string;
+    indexedAccountId: string | undefined;
+    networkId: string;
+    symbol: ISupportedSymbol;
+    provider: string;
+    vault: string | undefined;
+  }>(() => {
     const routeParams = route.params as any;

     const {
       accountId: routeAccountId,
       indexedAccountId: routeIndexedAccountId,
       networkId,
       symbol,
       provider,
       vault,
     } = routeParams;

     return {
       accountId: routeAccountId || activeAccount.account?.id || '',
       indexedAccountId:
         routeIndexedAccountId || activeAccount.indexedAccount?.id,
       networkId,
       symbol,
       provider,
       vault,
-      isFromShareLink: false,
     };
   }, [route.params, activeAccount]);
packages/kit/src/views/Earn/EarnHome.tsx (1)

225-283: Spacer <Stack h={tabPageHeight} /> is still a layout workaround.

The native+md branch still relies on an empty Stack plus an absolutely positioned header to reserve vertical space. This is brittle and hard to reason about.

Consider refactoring so the header participates in normal layout (padding/margin or a wrapping container) instead of using an empty spacer.

packages/kit/src/views/Discovery/pages/Browser/Browser.native.tsx (1)

380-391: Earn section visibility still duplicates conditions and lacks Freeze.

You still check selectedHeaderTab === ETranslations.global_earn twice (Stack display and showContent) and render EarnHomeWithProvider even when the Earn tab is hidden. Consider:

  • Relying on a single visibility condition, and
  • Wrapping EarnHomeWithProvider in Freeze so Earn logic pauses when the tab is not active.

This matches the earlier feedback on this block.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between df3c6e4 and a84502e.

📒 Files selected for processing (9)
  • packages/kit/src/views/Discovery/pages/Browser/Browser.native.tsx (2 hunks)
  • packages/kit/src/views/Earn/EarnHome.tsx (8 hunks)
  • packages/kit/src/views/Earn/components/EarnMainTabs.tsx (1 hunks)
  • packages/kit/src/views/Earn/pages/EarnProtocolDetails/components/ApyChart.tsx (1 hunks)
  • packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx (1 hunks)
  • packages/kit/src/views/Staking/pages/ManagePosition/index.tsx (1 hunks)
  • packages/shared/src/routes/staking.ts (3 hunks)
  • packages/shared/src/routes/tabDiscovery.ts (1 hunks)
  • packages/shared/src/routes/tabEarn.ts (1 hunks)
⏰ 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). (1)
  • GitHub Check: Analyze (javascript-typescript)
🔇 Additional comments (13)
packages/shared/src/routes/tabDiscovery.ts (1)

10-10: LGTM!

Clean type extension for Earn tab navigation. The optional earnTab param aligns well with the existing defaultTab pattern.

packages/shared/src/routes/tabEarn.ts (2)

19-26: LGTM!

The EarnProtocolDetails params cover all necessary context for protocol detail pages, including optional indexed account support for account-specific views.


27-32: Good separation of share link parameters.

Using friendly network names (like 'ethereum') instead of IDs makes share URLs more readable. The comment clarifies the intent well.

packages/kit/src/views/Earn/pages/EarnProtocolDetails/components/ApyChart.tsx (2)

126-166: LGTM!

APY history fetching and data transformation is well-structured. The timestamp conversion (ms to seconds) and sorting ensure chart compatibility. High/low calculations provide useful context.


265-293: Popover positioned correctly relative to hover point.

The popover uses hoverData.x and hoverData.y with transform to center horizontally. The Math.max(10, hoverData.y - 70) prevents edge clipping. Good implementation.

packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx (3)

87-104: LGTM!

The ManagersSection cleanly renders manager info with proper separators. The conditional rendering and index-based separator logic work correctly.


756-823: Well-structured route param resolution.

The resolvedParams memo handles both share links (with friendly network names) and normal navigation. Error handling with OneKeyLocalError for invalid network/symbol/provider is appropriate. Fallback to activeAccount for share links ensures graceful handling.


936-1016: Smart conditional footer rendering.

The footer logic differentiates between USDe (Receive/Trade) and other assets (Deposit/Withdraw), with proper button disabling based on loading/account state. Mobile-only rendering via gtMd check is appropriate.

packages/kit/src/views/Staking/pages/ManagePosition/index.tsx (1)

301-317: Confirm sceneName should be 'home'.

Line 305 sets sceneName to EAccountSelectorSceneName.home. Is 'home' the correct scene for a manage position page, or should it be a staking-specific scene?

packages/kit/src/views/Discovery/pages/Browser/Browser.native.tsx (1)

133-141: Route params wiring for Earn looks correct.

Destructuring { defaultTab, earnTab } = route?.params || {} is safe and keeps the header tab and Earn inner tab concerns separate. No changes needed here.

packages/kit/src/views/Earn/EarnHome.tsx (3)

288-327: Desktop/web Earn container and tabs wiring look good.

The non‑native branch neatly composes EarnPageContainer, overview + banners, and EarnMainTabs with a standard RefreshControl. Props passed to EarnMainTabs (defaultTab, onTabChange, loading flags, FAQ data) align with its interface.

No changes needed here.


330-355: Provider composition for Earn state looks correct.

Wrapping BasicEarnHome with AccountSelectorProviderMirror and EarnProviderMirror (using EJotaiContextStoreNames.earn) keeps Earn state scoped and avoids leaking store details to callers. Passing overrideDefaultTab={defaultTab} cleanly bridges external routing into the inner tabs.

Looks solid.


358-395: Earn→Discovery navigation bridge is clean and preserves tab selection.

useNavigateToNativeEarnPage reads the Earn route’s tab param and, on native+md, redirects into Discovery with defaultTab: global_earn and earnTab: tabParam. The main EarnHome component then still renders a normal Earn page on other platforms.

This keeps deep links working while unifying the iPad layout under Discovery. Nicely done.

Comment on lines +36 to +65
const tabNames = useMemo(
() => ({
assets: intl.formatMessage({
id: ETranslations.earn_available_assets,
}),
portfolio: intl.formatMessage({
id: ETranslations.earn_portfolio,
}),
faqs: intl.formatMessage({ id: ETranslations.global_faqs }),
}),
[intl],
);

const initialTabName = useMemo(() => {
if (defaultTab === 'portfolio') return tabNames.portfolio;
if (defaultTab === 'faqs') return tabNames.faqs;
return tabNames.assets;
}, [defaultTab, tabNames]);

// Switch tab when defaultTab changes (from route navigation)
useEffect(() => {
if (defaultTab && tabsRef.current) {
const targetTabName = initialTabName;
const currentTabName = tabsRef.current.getFocusedTab();
if (currentTabName !== targetTabName) {
tabsRef.current.jumpToTab(targetTabName);
}
}
}, [defaultTab, initialTabName]);

Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Avoid using translated labels as tab identifiers.

Tab names are the translated labels, and handleTabPress infers 'assets' | 'portfolio' | 'faqs' by comparing those strings. This is fragile if copy changes or two labels collide.

Prefer stable IDs for Tabs.Tab.name (e.g. 'assets' | 'portfolio' | 'faqs') and pass the localized label separately (via a label prop or custom tabBar renderer). That keeps routing logic robust against text changes.

🤖 Prompt for AI Agents
In packages/kit/src/views/Earn/components/EarnMainTabs.tsx around lines 36 to
65, the code currently uses translated labels as tab identifiers which is
fragile; change to stable tab IDs ('assets' | 'portfolio' | 'faqs') for
Tabs.Tab.name and keep translations only as a separate label property.
Concretely: replace tabNames so it maps id->label (e.g. { assets:
intl.formatMessage(...), ... }), derive initialTabId from defaultTab (return
'assets'|'portfolio'|'faqs' not translated text), update the effect to compare
tabsRef.current.getFocusedTab() to the target tab id and call jumpToTab with the
id, and update handleTabPress/any other comparisons to use the stable id values
rather than translated strings. Ensure Tabs render uses the id as name and the
localized string as label/children.

Comment on lines +74 to +141
return (
<Tabs.Container
ref={tabsRef}
renderTabBar={(tabBarProps) => {
const handleTabPress = (name: string) => {
tabBarProps.onTabPress?.(name);
if (onTabChange) {
if (name === tabNames.portfolio) {
onTabChange('portfolio');
} else if (name === tabNames.faqs) {
onTabChange('faqs');
} else {
onTabChange('assets');
}
}
};
return <Tabs.TabBar {...tabBarProps} onTabPress={handleTabPress} />;
}}
initialTabName={initialTabName}
{...containerProps}
>
<Tabs.Tab name={tabNames.assets}>
{isMobile ? (
<Tabs.ScrollView refreshControl={refreshControl}>
<YStack pt="$6" gap="$8">
<ProtocolsTabContent />
</YStack>
</Tabs.ScrollView>
) : (
<YStack>
<YStack pt="$6" gap="$8">
<ProtocolsTabContent />
</YStack>
</YStack>
)}
</Tabs.Tab>
<Tabs.Tab name={tabNames.portfolio}>
{isMobile ? (
<Tabs.ScrollView refreshControl={refreshControl}>
<YStack pt="$6" gap="$8">
<PortfolioTabContent />
</YStack>
</Tabs.ScrollView>
) : (
<YStack>
<YStack pt="$6" gap="$8">
<PortfolioTabContent />
</YStack>
</YStack>
)}
</Tabs.Tab>
<Tabs.Tab name={tabNames.faqs}>
{isMobile ? (
<Tabs.ScrollView refreshControl={refreshControl}>
<YStack px="$5" pt="$6" gap="$8">
<FAQContent faqList={faqList} isLoading={isFaqLoading} />
</YStack>
</Tabs.ScrollView>
) : (
<YStack>
<YStack px="$5" pt="$6" gap="$8">
<FAQContent faqList={faqList} isLoading={isFaqLoading} />
</YStack>
</YStack>
)}
</Tabs.Tab>
</Tabs.Container>
);
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Tighten typing of containerProps for better safety.

containerProps?: any hides the actual shape of props passed into Tabs.Container. If this surface is reused, consider introducing a dedicated type (e.g. ITabsContainerProps or ComponentProps<typeof Tabs.Container>) instead of any, so future changes are type‑checked.

🤖 Prompt for AI Agents
In packages/kit/src/views/Earn/components/EarnMainTabs.tsx around lines 74 to
141, containerProps is currently typed as any which hides the actual shape
passed into Tabs.Container; replace the any with a precise type (for example
React.ComponentProps<typeof Tabs.Container> or a dedicated interface like
ITabsContainerProps) in the component props definition, update any imports
(import React or use the existing React import) as needed, and use that typed
containerProps where spread into Tabs.Container so TypeScript will validate
props passed through.

Comment on lines +190 to +197
const banners = useMemo(
() => (
<Stack px="$5">
<BannerV2 data={earnBanners} onBannerPress={onBannerPress} />
</Stack>
),
[earnBanners, onBannerPress],
);
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

banners memo is always truthy; condition checks the wrong thing.

banners is a JSX element created in useMemo, so {banners ? (...) : null} will always render the wrapper, even when earnBanners is empty/undefined.

If you only want to show the banner area when there is data, check the data instead, for example:

const hasBanners = !!earnBanners?.length;
...
{hasBanners ? <YStack ...>{banners}</YStack> : null}

This keeps the layout cleaner when there are no banners.

🤖 Prompt for AI Agents
In packages/kit/src/views/Earn/EarnHome.tsx around lines 190 to 197, the
memoized variable `banners` is a JSX element so checking `{banners ? ... :
null}` always passes; instead derive a boolean like `hasBanners =
!!earnBanners?.length` and use that for conditional rendering. Update the
conditional to render the banner wrapper only when `hasBanners` is true (and
optionally only build the memoized JSX when there is data), keeping the current
dependencies `[earnBanners, onBannerPress]`.

Comment on lines +176 to +189
// Initialize selectedTabIndex based on defaultTab
const [selectedTabIndex, setSelectedTabIndex] = useState(() => {
if (defaultTab === 'withdraw') return 1;
return 0;
});

// Update selectedTabIndex when defaultTab changes from route
useEffect(() => {
if (defaultTab === 'withdraw') {
setSelectedTabIndex(1);
} else if (defaultTab === 'deposit') {
setSelectedTabIndex(0);
}
}, [defaultTab]);
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Simplify tab state management.

You're managing tab state in three places: selectedTabIndex (local), defaultTab (route params), and focusedTab (animation). This creates sync complexity. The useEffect also lacks a default case if defaultTab is neither 'withdraw' nor 'deposit'.

Consider deriving selectedTabIndex directly from route params instead of mirroring it in local state.

🤖 Prompt for AI Agents
In packages/kit/src/views/Staking/pages/ManagePosition/index.tsx around lines
176 to 189, remove the local useState and useEffect that mirror route param
defaultTab and instead derive selectedTabIndex directly from defaultTab (e.g.
const selectedTabIndex = defaultTab === 'withdraw' ? 1 : 0), ensuring you handle
any other/undefined defaultTab by treating it as the deposit (0) or explicit
fallback; keep focusedTab (animation state) separate and only update it where
animations require, and remove the effect that sets selectedTabIndex to avoid
duplicated state and synchronization issues.

Comment on lines +273 to +290
{selectedTabIndex === 0 ? (
<StakeSection
accountId={earnAccount?.account?.id || ''}
networkId={networkId}
tokenInfo={tokenInfo}
protocolInfo={protocolInfo}
isDisabled={depositDisabled}
/>
) : null}
{selectedTabIndex === 1 ? (
<WithdrawSection
accountId={earnAccount?.account?.id || ''}
networkId={networkId}
tokenInfo={tokenInfo}
protocolInfo={protocolInfo}
isDisabled={withdrawDisabled}
/>
) : 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

🧩 Analysis chain

Verify empty string is valid for accountId.

Lines 275 and 284 fall back to an empty string when earnAccount?.account?.id is missing. If StakeSection and WithdrawSection expect a valid account ID or undefined, passing '' could cause subtle bugs.

Check if an empty string is acceptable, or pass undefined instead.


🏁 Script executed:

#!/bin/bash
# Check how StakeSection and WithdrawSection handle accountId prop
ast-grep --pattern 'function StakeSection({ $$$, accountId, $$$ }) {
  $$$
}'

ast-grep --pattern 'function WithdrawSection({ $$$, accountId, $$$ }) {
  $$$
}'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find StakeSection and WithdrawSection component files
fd -type f \( -name "*StakeSection*" -o -name "*WithdrawSection*" \) | head -20

Length of output: 170


🏁 Script executed:

#!/bin/bash
# Search for StakeSection component definition
rg -n "StakeSection" --type=ts --type=tsx -A 5 | head -50

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Search for WithdrawSection component definition  
rg -n "WithdrawSection" --type=ts --type=tsx -A 5 | head -50

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Find component files in staking views directory
fd "Stake|Withdraw" packages/kit/src/views/Staking/ | grep -E "\.(ts|tsx)$" | head -20

Length of output: 303


🏁 Script executed:

#!/bin/bash
# Search for StakeSection component definition (arrow function or export)
rg -n "StakeSection\s*[:=]|export.*StakeSection" -A 10 packages/kit/src/views/Staking/ | head -60

Length of output: 1121


🏁 Script executed:

#!/bin/bash
# Search for WithdrawSection component definition (arrow function or export)
rg -n "WithdrawSection\s*[:=]|export.*WithdrawSection" -A 10 packages/kit/src/views/Staking/ | head -60

Length of output: 1157


🏁 Script executed:

#!/bin/bash
# Look for prop interfaces/types for these components
rg -n "interface.*Stake|interface.*Withdraw|type.*Stake|type.*Withdraw" packages/kit/src/views/Staking/ -A 3 | head -40

Length of output: 3476


🏁 Script executed:

#!/bin/bash
# Check how accountId is used in StakeSection component
rg -n "accountId" packages/kit/src/views/Staking/pages/ManagePosition/components/StakeSection.tsx -C 3

Length of output: 3178


🏁 Script executed:

#!/bin/bash
# Check how accountId is used in WithdrawSection component
rg -n "accountId" packages/kit/src/views/Staking/pages/ManagePosition/components/WithdrawSection.tsx -C 3

Length of output: 1220


🏁 Script executed:

#!/bin/bash
# Check parent component for guards on earnAccount before rendering StakeSection/WithdrawSection
rg -n "earnAccount" packages/kit/src/views/Staking/pages/ManagePosition/index.tsx -B 5 -A 1 | grep -E "(earnAccount|selectedTabIndex|StakeSection|WithdrawSection)" | head -40

Length of output: 446


🏁 Script executed:

#!/bin/bash
# Check if there's a guard preventing empty accountId scenarios
rg -n "earnAccount\?" packages/kit/src/views/Staking/pages/ManagePosition/index.tsx -C 2 | head -60

Length of output: 953


🏁 Script executed:

#!/bin/bash
# Look at the full context around lines 273-290 to see if earnAccount is validated
sed -n '260,295p' packages/kit/src/views/Staking/pages/ManagePosition/index.tsx

Length of output: 1196


Pass undefined instead of empty string for missing accountId.

Lines 275 and 284 use earnAccount?.account?.id || '', falling back to empty string. Both StakeSection and WithdrawSection pass accountId directly to API calls like backgroundApiProxy.serviceAccount.getAccount({ accountId }) and useUniversalWithdraw({ accountId }). An empty string satisfies the TypeScript type requirement but causes runtime failures when the API receives it.

Change the fallback to undefined:

accountId={earnAccount?.account?.id}

This lets child components handle the missing ID gracefully or prevents rendering the sections entirely if they can't function without a valid account ID.

🤖 Prompt for AI Agents
In packages/kit/src/views/Staking/pages/ManagePosition/index.tsx around lines
273 to 290, the code currently falls back to an empty string for missing account
IDs (earnAccount?.account?.id || ''), which passes an invalid accountId to child
components and downstream APIs; change the props to pass undefined when the ID
is missing by using earnAccount?.account?.id (remove the || '' fallback) for
both StakeSection and WithdrawSection so children can handle absent IDs or
prevent rendering when an ID is required.

Comment on lines +33 to +34
symbol?: string;
provider?: string;
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

Clarify the redundancy between nested and direct fields.

IDetailPageInfoParams already has protocolInfo and tokenInfo (which contain symbol and provider). Adding symbol and provider as direct optional fields creates redundancy. Which source should consumers prefer? Are both needed?

Verify the usage pattern:


🏁 Script executed:

#!/bin/bash
# Check how routes using IDetailPageInfoParams access symbol/provider
rg -nP --type=ts 'protocolInfo\.symbol|tokenInfo\.symbol|params\.symbol' -g '**/Staking/**' -C2

# Check if the direct fields are used alongside nested objects
rg -nP --type=ts 'IDetailPageInfoParams' -g '**/Staking/**' -A10

Length of output: 1712


🏁 Script executed:

#!/bin/bash
# Get full context for EarnActionIcon assignment
rg -nP --type=ts 'protocolInfo\.symbol' packages/kit/src/views/Staking/components/ProtocolDetails/EarnActionIcon.tsx -B10 -A3

# Get full context for InvestmentDetails assignment
rg -nP --type=ts 'symbol: tokenInfo\.symbol' packages/kit/src/views/Staking/pages/InvestmentDetails/index.tsx -B10 -A3

# Search for actual usage of params.symbol or symbol field from route params
rg -nP --type=ts 'params\.symbol|route\.params\.symbol' -g '**/Staking/**' -B2 -A2

Length of output: 1224


🏁 Script executed:

# Wait for results

Length of output: 59


🏁 Script executed:

#!/bin/bash
# Find where route params are consumed - look for useRoute or route.params patterns
rg -nP --type=ts '\buseRoute\b|\broute\.params\b' -g '**/Staking/**' -B2 -A5

# Check what component/page handles EarnProtocolDetails route
rg -nP --type=ts 'EarnProtocolDetails|ETabEarnRoutes\.EarnProtocolDetails' -g '**/Staking/**' -B3 -A3

Length of output: 12611



🏁 Script executed:

#!/bin/bash
# Get lines 58-65 from staking.ts to see ManagePosition route definition
sed -n '58,65p' packages/shared/src/routes/staking.ts

Length of output: 202


Remove the redundancy concern—the added fields serve a purpose.

The symbol and provider fields aren't redundant. Multiple routes (ProtocolDetails, ProtocolDetailsV2, PortfolioDetails, ManagePosition) access them directly from route params instead of nested objects. This design is intentional.

However, the ManagePosition route definition has a type safety problem. It's missing accountId and indexedAccountId, which the component actually needs (lines 99-100). The component works around this by casting params to any, defeating type checking.

  [EModalStakingRoutes.ManagePosition]: {
    networkId: string;
    symbol: string;
    provider: string;
    details?: IStakeProtocolDetails;
    vault?: string;
    tab?: 'deposit' | 'withdraw';
  };

Add the missing fields to the type definition or update the component to not access them.

🤖 Prompt for AI Agents
In packages/shared/src/routes/staking.ts around lines 33-34 and the
ManagePosition route definition near lines ~99-100, the ManagePosition route
type is missing accountId and indexedAccountId which the component actually
reads (causing an any cast). Update the ManagePosition route params type to
include accountId: string and indexedAccountId: string (or the correct
optionality if they can be undefined), so the component can access them with
full type safety; alternatively, if those fields truly should not be passed via
route, update the component to obtain them from the correct source instead of
route params.

Comment on lines +14 to +18
[ETabEarnRoutes.EarnProtocols]: {
symbol: string;
filterNetworkId?: string;
logoURI?: string;
};
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider stronger typing for symbol parameter.

The symbol param is currently string, but the codebase uses ISupportedSymbol elsewhere (see line 560 in index.tsx). Consider using that type here for compile-time safety.

Apply this diff if ISupportedSymbol is available at this level:

+import type { ISupportedSymbol } from '@onekeyhq/shared/types/earn';
+
 export type ITabEarnParamList = {
   ...
   [ETabEarnRoutes.EarnProtocols]: {
-    symbol: string;
+    symbol: ISupportedSymbol;
     filterNetworkId?: string;
     logoURI?: string;
   };

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/shared/src/routes/tabEarn.ts around lines 14 to 18, the symbol
property is typed as string; change it to use the project's ISupportedSymbol
type for stronger compile-time safety and update any affected call sites. Import
ISupportedSymbol from its declaring module (the path where ISupportedSymbol is
defined in this repo) at the top of the file, replace "symbol: string" with
"symbol: ISupportedSymbol", and ensure the file compiles and tests pass.

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: 12

♻️ Duplicate comments (11)
packages/kit/src/views/Staking/pages/ManagePosition/index.tsx (4)

92-120: Fix the type annotation to include returned fields.

Past review flagged this: you return accountId and indexedAccountId (lines 111-113), but the type annotation (lines 92-98) omits them. TypeScript will reject this. Add both fields to the type or remove the explicit generic.

Apply this diff:

  const resolvedParams = useMemo<{
+   accountId: string;
+   indexedAccountId: string | undefined;
    networkId: string;
    symbol: ISupportedSymbol;
    provider: string;
    vault: string | undefined;
    isFromShareLink: boolean;
  }>(() => {

179-192: Simplify tab state management.

You mirror defaultTab (route param) into selectedTabIndex (local state) via useEffect. This duplicates state. Derive selectedTabIndex directly from defaultTab:

const selectedTabIndex = defaultTab === 'withdraw' ? 1 : 0;

Keep focusedTab (animation state) separate.


247-249: Remove the as any cast when calling setParams.

Line 249 bypasses type safety. Ensure IModalStakingParamList[EModalStakingRoutes.ManagePosition] includes a tab field. Then remove the cast.


307-307: Pass undefined instead of empty string for missing accountId.

Lines 307 and 316 use earnAccount?.account?.id || ''. An empty string satisfies TypeScript but breaks at runtime when child components call APIs with it. Past review confirmed both StakeSection and WithdrawSection pass accountId to background APIs that fail on ''.

Change both lines:

- accountId={earnAccount?.account?.id || ''}
+ accountId={earnAccount?.account?.id}

Child components must handle undefined or prevent rendering when accountId is absent.

Also applies to: 316-316

packages/kit/src/views/Staking/components/UniversalWithdraw/index.tsx (2)

541-541: Fix crash when rewards is undefined.

transactionConfirmation?.rewards.map(...) will crash if rewards is undefined or null. Optional chaining on transactionConfirmation isn't enough.

Apply this diff:

-          {transactionConfirmation?.rewards.map((reward) => {
+          {transactionConfirmation?.rewards?.map((reward) => {

694-709: Percentage keyboard ignores disabled state on mobile.

PercentageStageOnKeyboard at line 706 remains interactive even when amountInputDisabled is true. Users can tap percentage buttons, triggering onSelectPercentageStage and changing the amount despite the disabled state. This breaks the disabled form surface on mobile.

Consider passing a disabled prop or conditionally rendering the keyboard:

          <PercentageStageOnKeyboard
            onSelectPercentageStage={onSelectPercentageStage}
+           disabled={amountInputDisabled}
          />

Or conditionally render it:

-          <PercentageStageOnKeyboard
-            onSelectPercentageStage={onSelectPercentageStage}
-          />
+          {!amountInputDisabled ? (
+            <PercentageStageOnKeyboard
+              onSelectPercentageStage={onSelectPercentageStage}
+            />
+          ) : null}
packages/kit/src/views/Earn/pages/EarnProtocolDetails/components/ApyChart.tsx (1)

87-111: Fix hover handling for zero values.

The truthy checks at line 99 fail when time or price equals zero. Legitimate zero-yield data points lose hover feedback.

Replace truthy checks with explicit null/undefined checks:

   const handleHover = useCallback(
     ({
       time,
       price,
       x,
       y,
     }: {
       time?: number;
       price?: number;
       x?: number;
       y?: number;
     }) => {
-      if (time && price && x !== undefined && y !== undefined) {
+      if (
+        time !== undefined &&
+        time !== null &&
+        price !== undefined &&
+        price !== null &&
+        x !== undefined &&
+        y !== undefined
+      ) {
         setHoverData({
           time,
           apy: price,
           x,
           y,
         });
       } else {
         setHoverData(null);
       }
     },
     [],
   );
packages/kit/src/views/Earn/components/PortfolioTabContent.tsx (1)

149-176: Remove duplicate keys from child components.

Lines 159 and 165 incorrectly apply key={index} to EarnText children. Only the parent XStack (the direct map child) needs the key.

Apply this diff:

       {asset.assetsStatus?.map((status, index) => (
         <XStack key={index} ai="center">
           <EarnText
-            key={index}
             mr="$2"
             size="$bodyLgMedium"
             text={status.title}
           />
           <EarnText
-            key={index}
             mr="$2"
             size="$bodyLgMedium"
             color="$textSubdued"
             text={status.description}
           />
           <EarnTooltip tooltip={status.tooltip} />
         </XStack>
       ))}
packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx (2)

370-383: Fix countdown visibility window.

Line 373 checks endTime < now, which only renders the alert after the end time passes. Flip the comparison so it displays while the current time sits between startTime and endTime.

Apply this diff:

             {detailInfo?.countDownAlert?.startTime &&
             detailInfo?.countDownAlert?.endTime &&
             now > detailInfo.countDownAlert.startTime &&
-            detailInfo.countDownAlert.endTime < now ? (
+            detailInfo.countDownAlert.endTime > now ? (
               <YStack pb="$1">

826-844: Use resolved indexedAccountId consistently.

Line 826 destructures indexedAccountId from resolvedParams, but line 840 passes indexedAccount?.id from the activeAccount hook instead. This inconsistency means share-link navigation might intend a different account than what's actually used.

Apply this diff:

   const { accountId, networkId, indexedAccountId, symbol, provider, vault } =
     resolvedParams;
 
   const {
     earnAccount,
     detailInfo,
     tokenInfo,
     protocolInfo,
     isLoading,
     refreshData,
     refreshAccount,
   } = useProtocolDetailData({
     accountId,
     networkId,
-    indexedAccountId: indexedAccount?.id,
+    indexedAccountId,
     symbol,
     provider,
     vault,
   });
packages/kit/src/views/Earn/hooks/useEarnPortfolio.ts (1)

394-398: Move fetchedKeys.add() inside processResult.

Keys are added to fetchedKeys before the fetch resolves. When a position goes to zero, fetchInvestmentDetail returns null, but the key stays in fetchedKeys. The final sweep at lines 454-460 won't remove the stale entry, so users still see the position after cashing out.

Only mark a key as fetched when we have data:

-      const fetchPromises = pairsWithType.map(({ params, isAirdrop }) => {
-        const key = createInvestmentKey(params);
-        fetchedKeys.add(key);
-        return fetchInvestmentDetail(params, isAirdrop, requestId);
-      });
+      const fetchPromises = pairsWithType.map(({ params, isAirdrop }) => {
+        return fetchInvestmentDetail(params, isAirdrop, requestId);
+      });

And in processResult:

       const processResult = (
         result: Awaited<ReturnType<typeof fetchInvestmentDetail>>,
       ) => {
         if (!result || isRequestStale(requestId)) return;
 
         const { key, investment } = result;
+        fetchedKeys.add(key);
 
         // Merge with existing data for the same key
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a84502e and 56746ac.

⛔ Files ignored due to path filters (19)
  • packages/shared/src/locale/json/bn.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/de.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/en_US.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/es.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/fr_FR.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/hi_IN.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/id.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/it_IT.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/ja_JP.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/ko_KR.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/pt.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/pt_BR.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/ru.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/th_TH.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/uk_UA.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/vi.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/zh_CN.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/zh_HK.json is excluded by !packages/shared/src/locale/json/*.json
  • packages/shared/src/locale/json/zh_TW.json is excluded by !packages/shared/src/locale/json/*.json
📒 Files selected for processing (11)
  • packages/kit/src/views/Earn/components/PortfolioTabContent.tsx (1 hunks)
  • packages/kit/src/views/Earn/hooks/useEarnPortfolio.ts (1 hunks)
  • packages/kit/src/views/Earn/pages/EarnProtocolDetails/components/ApyChart.tsx (1 hunks)
  • packages/kit/src/views/Earn/pages/EarnProtocolDetails/hooks/useProtocolDetailBreadcrumb.ts (1 hunks)
  • packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx (1 hunks)
  • packages/kit/src/views/Staking/components/UniversalWithdraw/index.tsx (10 hunks)
  • packages/kit/src/views/Staking/pages/ManagePosition/hooks/useManagePage.ts (1 hunks)
  • packages/kit/src/views/Staking/pages/ManagePosition/hooks/useProtocolDetails.ts (1 hunks)
  • packages/kit/src/views/Staking/pages/ManagePosition/index.tsx (1 hunks)
  • packages/shared/src/locale/enum/translations.ts (14 hunks)
  • packages/shared/types/staking.ts (11 hunks)
🧰 Additional context used
🪛 ESLint
packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx

[error] 30-36: Import "ETabEarnRoutes" is only used as types.

(@typescript-eslint/consistent-type-imports)

packages/kit/src/views/Staking/components/UniversalWithdraw/index.tsx

[error] 635-637: Replace ⏎··························?·'180deg'⏎························· with ·?·'180deg'

(prettier/prettier)

⏰ 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: Analyze (javascript-typescript)
  • GitHub Check: Analyze (java-kotlin)
🔇 Additional comments (8)
packages/shared/src/locale/enum/translations.ts (1)

1-2: Note: File is auto-generated.

Changes to this file won't persist. Address naming issues in the source locale files before running yarn fetch:locale.

packages/kit/src/views/Staking/pages/ManagePosition/hooks/useProtocolDetails.ts (1)

60-87: Clean BigNumber handling with proper NaN guard.

The balance parsing correctly falls back to '0' when the value is invalid or missing. Dependencies are comprehensive.

packages/kit/src/views/Staking/pages/ManagePosition/hooks/useManagePage.ts (1)

90-114: Good BigNumber handling with NaN guard.

Balance parsing (lines 95-96) correctly falls back to '0' for invalid values. Dependencies are comprehensive.

packages/kit/src/views/Staking/components/UniversalWithdraw/index.tsx (3)

237-239: Short-circuit API call when disabled—good.

Prevents unnecessary API calls when the component is disabled.


370-372: Clean derivation for input disabled state.

Combines isDisabled and initialAmount checks clearly.


670-692: Desktop footer renders inline—clean split.

The desktop footer rendering inline with a full-width button is clear and straightforward.

packages/shared/types/staking.ts (1)

697-699: The data field was already optional—no breaking change here.

The review comment claims data changed from required to optional, but it was already optional (data?: { text: IEarnText; }) in the previous commit. This PR doesn't modify the field. All existing consumers already use optional chaining, so there's no breaking change to address.

Likely an incorrect or invalid review comment.

packages/kit/src/views/Earn/pages/EarnProtocolDetails/hooks/useProtocolDetailBreadcrumb.ts (1)

1-101: LGTM!

Clean hook implementation with proper error handling and conditional breadcrumb logic. The guard clauses and fallbacks ensure robust behavior.

Comment on lines +700 to +713
{investments.map((item, index) => {
const showDivider = index < investments.length - 1;
const key = `${item.protocol.providerDetail.code}_${
item.protocol.vaultName || ''
}_${item.network.networkId}`;

return (
<>
<PortfolioItem key={key} portfolioItem={item} />
{showDivider ? <Divider my="$4" /> : null}
</>
);
})}
</YStack>
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

Move key to Fragment.

The map returns a Fragment with PortfolioItem and conditional Divider, but the key is on PortfolioItem instead of the Fragment. This can break React reconciliation.

Apply this diff:

       {investments.map((item, index) => {
         const showDivider = index < investments.length - 1;
         const key = `${item.protocol.providerDetail.code}_${
           item.protocol.vaultName || ''
         }_${item.network.networkId}`;
 
         return (
-          <>
-            <PortfolioItem key={key} portfolioItem={item} />
+          <Fragment key={key}>
+            <PortfolioItem portfolioItem={item} />
             {showDivider ? <Divider my="$4" /> : null}
-          </>
+          </Fragment>
         );
       })}

Add Fragment to imports:

-import { memo, useCallback, useEffect, useMemo } from 'react';
+import { Fragment, memo, useCallback, useEffect, useMemo } from 'react';
📝 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
{investments.map((item, index) => {
const showDivider = index < investments.length - 1;
const key = `${item.protocol.providerDetail.code}_${
item.protocol.vaultName || ''
}_${item.network.networkId}`;
return (
<>
<PortfolioItem key={key} portfolioItem={item} />
{showDivider ? <Divider my="$4" /> : null}
</>
);
})}
</YStack>
{investments.map((item, index) => {
const showDivider = index < investments.length - 1;
const key = `${item.protocol.providerDetail.code}_${
item.protocol.vaultName || ''
}_${item.network.networkId}`;
return (
<Fragment key={key}>
<PortfolioItem portfolioItem={item} />
{showDivider ? <Divider my="$4" /> : null}
</Fragment>
);
})}
</YStack>
Suggested change
{investments.map((item, index) => {
const showDivider = index < investments.length - 1;
const key = `${item.protocol.providerDetail.code}_${
item.protocol.vaultName || ''
}_${item.network.networkId}`;
return (
<>
<PortfolioItem key={key} portfolioItem={item} />
{showDivider ? <Divider my="$4" /> : null}
</>
);
})}
</YStack>
import { Fragment, memo, useCallback, useEffect, useMemo } from 'react';
🤖 Prompt for AI Agents
In packages/kit/src/views/Earn/components/PortfolioTabContent.tsx around lines
700 to 713, the key is applied to the PortfolioItem instead of the surrounding
Fragment which breaks reconciliation; import Fragment from 'react' (or use
React.Fragment), move the computed key onto the Fragment (e.g. <Fragment
key={key}> ... </Fragment>), and remove the key prop from PortfolioItem so the
list item key is applied to the Fragment that wraps PortfolioItem and the
conditional Divider.

Comment on lines +30 to +36
import {
EModalReceiveRoutes,
EModalRoutes,
EModalStakingRoutes,
ETabEarnRoutes,
ETabRoutes,
} from '@onekeyhq/shared/src/routes';
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

Use type-only import.

ETabEarnRoutes is only used as a type. Use a type-only import to clarify intent and potentially improve tree-shaking.

Apply this diff:

 import { ETranslations } from '@onekeyhq/shared/src/locale';
-import type { ITabEarnParamList } from '@onekeyhq/shared/src/routes';
+import type {
+  ITabEarnParamList,
+  ETabEarnRoutes,
+} from '@onekeyhq/shared/src/routes';
 import {
   EModalReceiveRoutes,
   EModalRoutes,
   EModalStakingRoutes,
-  ETabEarnRoutes,
   ETabRoutes,
 } from '@onekeyhq/shared/src/routes';

Based on learnings

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 ESLint

[error] 30-36: Import "ETabEarnRoutes" is only used as types.

(@typescript-eslint/consistent-type-imports)

🤖 Prompt for AI Agents
packages/kit/src/views/Earn/pages/EarnProtocolDetails/index.tsx around lines 30
to 36, ETabEarnRoutes is imported but only used as a type; change the import to
a type-only import (e.g., use "import type { ETabEarnRoutes } from '...'" or
split it out as an import type line) so the intent is explicit and to aid
tree-shaking, keeping other value imports unchanged.

Comment on lines +635 to +637
open && !isAccordionTriggerDisabled
? '180deg'
: '0deg'
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

Fix prettier formatting.

ESLint reports a formatting issue with the ternary operator.

Apply this diff:

                      rotate={
-                        open && !isAccordionTriggerDisabled
-                          ? '180deg'
-                          : '0deg'
+                        open && !isAccordionTriggerDisabled ? '180deg' : '0deg'
                      }
📝 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
open && !isAccordionTriggerDisabled
? '180deg'
: '0deg'
rotate={
open && !isAccordionTriggerDisabled ? '180deg' : '0deg'
}
🧰 Tools
🪛 ESLint

[error] 635-637: Replace ⏎··························?·'180deg'⏎························· with ·?·'180deg'

(prettier/prettier)

🤖 Prompt for AI Agents
In packages/kit/src/views/Staking/components/UniversalWithdraw/index.tsx around
lines 635 to 637, the ternary expression for the rotation value is misformatted
by Prettier; rewrite the expression to meet Prettier rules (either keep the
whole ternary on one line or break lines consistently with surrounding
JSX/props), e.g. place the condition, question-mark, consequent and colon on the
same line or wrap the ternary in parentheses so Prettier can format it without
introducing awkward line breaks; update the file accordingly to satisfy
ESLint/Prettier.

Comment on lines +116 to +176
const protocolInfo: IProtocolInfo | undefined = useMemo(() => {
if (!managePageData) {
return undefined;
}

// Find the matching protocol from protocol list
const matchingProtocol = protocolList?.find(
(item: IStakeProtocolListItem) =>
item.provider.name.toLowerCase() === provider.toLowerCase() &&
item.network.networkId === networkId &&
(!vault || item.provider.vault === vault),
);

// Get withdraw action from managePageData
const withdrawAction = managePageData.withdraw as
| IEarnWithdrawActionIcon
| undefined;

return {
symbol,
provider,
vault,
networkId,
earnAccount,
activeBalance: managePageData.withdraw?.data?.balance,
stakeTag: buildLocalTxStatusSyncId({
providerName: provider,
tokenSymbol: symbol,
}),
providerDetail: {
name: matchingProtocol?.provider.name || provider,
logoURI: matchingProtocol?.provider.logoURI || '',
},
// withdraw
withdrawAction,
overflowBalance: managePageData.nums?.overflow,
maxUnstakeAmount: managePageData.nums?.maxUnstakeAmount,
minUnstakeAmount: managePageData.nums?.minUnstakeAmount,
// staking
minTransactionFee: managePageData.nums?.minTransactionFee,
remainingCap: managePageData.nums?.remainingCap,
// claim
claimable: managePageData.nums?.claimable,
// approve
approve: managePageData.approve
? {
allowance: managePageData.approve.allowance,
approveType: managePageData.approve.approveType as any,
approveTarget: managePageData.approve.approveTarget,
}
: undefined,
} as IProtocolInfo;
}, [
managePageData,
protocolList,
symbol,
provider,
vault,
networkId,
earnAccount,
]);
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

Remove the as any cast for approveType.

Line 163 uses as any to force the type. If managePageData.approve.approveType doesn't match IProtocolInfo['approve']['approveType'], fix the type definition or add proper type mapping. Don't bypass the type system.

vault: string | undefined;
isFromShareLink: boolean;
}>(() => {
const routeParams = route.params 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

Remove the as any cast on route params.

as any disables type checking. If routeParams needs all these fields, define the correct type for EModalStakingRoutes.ManagePosition in your route param list.

🤖 Prompt for AI Agents
packages/kit/src/views/Staking/pages/ManagePosition/index.tsx around line 99:
the code currently uses "const routeParams = route.params as any", which
disables type checking; remove the "as any" cast and instead provide the correct
typed params for the ManagePosition route by adding or updating the route param
type for EModalStakingRoutes.ManagePosition in your route param list (declare
the shape of params there) and then type route.params appropriately (use the
route prop generic or a RouteProp mapped to that route key) so routeParams is
strongly typed and all fields are type-checked.

id_refer_a_friend_desc = 'id.refer_a_friend_desc',
identical_name_asset_alert = 'identical_name_asset_alert',
import_backup_password_desc = 'import_backup_password_desc',
import_hardware_phrases_warning = 'import_hardware_phrases_warning',
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Check plural usage: "phrases" vs "phrase".

import_hardware_phrases_warning uses plural "phrases". Standard terminology is "recovery phrase" (singular). Verify if plural is intentional.

Comment on lines +715 to +727
export interface IEarnWithdrawActionData {
type: 'withdraw' | 'withdrawOrder';
disabled: boolean;
text: IEarnText;
data?: {
balance?: string;
token?: {
info: IEarnToken;
price: string;
};
text?: IEarnText;
};
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider using enum for action type.

Line 716 uses string literal union 'withdraw' | 'withdrawOrder', but EStakingActionType already defines these values. Using the enum improves type safety and maintainability.

Apply this change:

-export interface IEarnWithdrawActionData {
-  type: 'withdraw' | 'withdrawOrder';
+export interface IEarnWithdrawActionData {
+  type: EStakingActionType.Withdraw | EStakingActionType.WithdrawOrder;
   disabled: boolean;
   text: IEarnText;
📝 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 interface IEarnWithdrawActionData {
type: 'withdraw' | 'withdrawOrder';
disabled: boolean;
text: IEarnText;
data?: {
balance?: string;
token?: {
info: IEarnToken;
price: string;
};
text?: IEarnText;
};
}
export interface IEarnWithdrawActionData {
type: EStakingActionType.Withdraw | EStakingActionType.WithdrawOrder;
disabled: boolean;
text: IEarnText;
data?: {
balance?: string;
token?: {
info: IEarnToken;
price: string;
};
text?: IEarnText;
};
}
🤖 Prompt for AI Agents
In packages/shared/types/staking.ts around lines 715 to 727, the
IEarnWithdrawActionData.type property uses the string literal union 'withdraw' |
'withdrawOrder' but EStakingActionType already defines these values; change the
property to use EStakingActionType instead (e.g. type: EStakingActionType),
import EStakingActionType at the top of the file if needed, and update any
nearby code or callers to reference the enum members
(EStakingActionType.withdraw / EStakingActionType.withdrawOrder) as required to
satisfy types.

Comment on lines +735 to +739
approve?: {
allowance: string;
approveType: string;
approveTarget: string;
};
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Type inconsistency: approveType should use enum.

Line 737 uses approveType: string but EApproveType enum exists (line 71-74). Use the enum for type safety.

Apply this change:

   approve?: {
     allowance: string;
-    approveType: string;
+    approveType: EApproveType;
     approveTarget: string;
   };
📝 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
approve?: {
allowance: string;
approveType: string;
approveTarget: string;
};
approve?: {
allowance: string;
approveType: EApproveType;
approveTarget: string;
};
🤖 Prompt for AI Agents
In packages/shared/types/staking.ts around lines 735 to 739, the approve object
declares approveType as a string but the EApproveType enum is defined earlier
(lines 71–74); change the approveType property type from string to EApproveType
and update any local usages to pass EApproveType members (or cast appropriately)
so the type aligns with the existing enum.

Comment on lines +1209 to +1215
buttons: {
type: string;
text: {
text: string;
};
disabled: boolean;
}[];
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Loose typing: buttons[].type should be more specific.

Line 1210 uses generic string for button type, but specific button types exist (EStakingActionType, EClaimType). This loses type safety.

Consider creating a union of valid button type strings or referencing existing enums:

     buttons: {
-      type: string;
+      type: EStakingActionType | EClaimType | 'history';
       text: {
         text: string;
       };
       disabled: boolean;
     }[];
📝 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
buttons: {
type: string;
text: {
text: string;
};
disabled: boolean;
}[];
buttons: {
type: EStakingActionType | EClaimType | 'history';
text: {
text: string;
};
disabled: boolean;
}[];
🤖 Prompt for AI Agents
In packages/shared/types/staking.ts around lines 1209 to 1215, the
buttons[].type property is declared as a loose string; replace it with a precise
union referencing the existing enums (e.g. EStakingActionType | EClaimType) or
an explicit union of valid string literals so the compiler enforces allowed
button types; update the import/exports to include those enums if needed and
adjust any call sites to satisfy the new type (or add type assertions where a
literal is guaranteed).

Comment on lines +1433 to +1437
export interface IApyHistoryResponse {
code: number;
message: string;
data: IApyHistoryItem[];
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Mixes API response structure with domain types.

IApyHistoryResponse includes code and message fields typical of API responses. Domain types should be separate from transport wrappers. Consider unwrapping at the service layer and keeping just IApyHistoryItem[].

Alternative structure:

// Domain type
export type IApyHistory = IApyHistoryItem[];

// Or if response wrapper is needed, make it generic:
export interface IApiResponse<T> {
  code: number;
  message: string;
  data: T;
}

// Then use: IApiResponse<IApyHistoryItem[]>
🤖 Prompt for AI Agents
In packages/shared/types/staking.ts around lines 1433 to 1437, the
IApyHistoryResponse type mixes transport-layer fields (code, message) with
domain data; extract the domain type and keep transport wrappers separate by
replacing IApyHistoryResponse with a domain-only type (e.g., export type
IApyHistory = IApyHistoryItem[]) and, if a response wrapper is needed, add a
generic IApiResponse<T> (export interface IApiResponse<T> { code: number;
message: string; data: T; }) and update callers to unwrap
IApiResponse<IApyHistory> at the service layer so domain layers only consume
IApyHistory.

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

♻️ Duplicate comments (3)
packages/kit/src/views/Earn/EarnHome.tsx (2)

203-210: Tidy up tab header height logic and spacer usage

The tab header height handling is a bit confusing:

  • The comment says “Use the actual measured height without arbitrary adjustments” but you still subtract 20.
  • An empty <Stack h={tabPageHeight} /> is used only as a spacer for the absolutely positioned header.

To make this clearer and less brittle:

  • Extract the 20 into a named constant (e.g. TAB_HEADER_OFFSET) or drop it if not needed.
  • Either:
    • Move the spacer behavior into the header/container styles (padding/margin/top offset), or
    • Replace the empty Stack with a small named component (e.g. <TabHeaderSpacer height={tabPageHeight} />) so intent is explicit.

This will make the layout easier to reason about and maintain.

Also applies to: 226-285


191-198: Use banner data to drive rendering, not the JSX element

banners is always a JSX element from useMemo, so checks like {banners ? (...) : null} will always render the wrapper, even when earnBanners is empty/undefined. That was flagged before and still applies.

Consider tracking a hasBanners flag and only building/rendering when you actually have data:

-  const banners = useMemo(
-    () => (
-      <Stack px="$5">
-        <BannerV2 data={earnBanners} onBannerPress={onBannerPress} />
-      </Stack>
-    ),
-    [earnBanners, onBannerPress],
-  );
+  const hasBanners = Array.isArray(earnBanners) && earnBanners.length > 0;
+
+  const banners = useMemo(
+    () =>
+      !hasBanners ? null : (
+        <Stack px="$5">
+          <BannerV2 data={earnBanners} onBannerPress={onBannerPress} />
+        </Stack>
+      ),
+    [earnBanners, hasBanners, onBannerPress],
+  );

Then use hasBanners for the wrappers:

-                  {banners ? (
+                  {hasBanners ? (
@@
-          {banners ? (
+          {hasBanners ? (

This keeps the layout clean when no banners exist.

Also applies to: 253-263, 306-318

packages/kit/src/views/Earn/components/Overview.tsx (1)

125-192: Guard rebate lists before mapping to avoid runtime crashes

You still call .map on rebateData?.distributed and rebateData?.undistributed. If either list is null/undefined, this will throw and break the popover. Also, needDivider should use the same guarded arrays.

Consider normalizing the arrays once after the null check and using those everywhere:

   if (!rebateData) {
     return null;
   }
+
+  const distributedItems = rebateData.distributed ?? [];
+  const undistributedItems = rebateData.undistributed ?? [];
 
   return (
@@
-            {isEmpty(rebateData?.distributed) ? null : (
+            {isEmpty(distributedItems) ? null : (
@@
-            {rebateData?.distributed.map((item, index) => {
+            {distributedItems.map((item, index) => {
               const needDivider =
-                index === rebateData.distributed.length - 1 &&
-                !isEmpty(rebateData?.undistributed);
+                index === distributedItems.length - 1 &&
+                !isEmpty(undistributedItems);
@@
-            {isEmpty(rebateData?.undistributed) ? null : (
+            {isEmpty(undistributedItems) ? null : (
@@
-            {rebateData?.undistributed.map((item, index) => {
+            {undistributedItems.map((item, index) => {

This keeps rendering safe even when the API omits one of the lists.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 56746ac and 24fd9d0.

📒 Files selected for processing (2)
  • packages/kit/src/views/Earn/EarnHome.tsx (8 hunks)
  • packages/kit/src/views/Earn/components/Overview.tsx (1 hunks)
⏰ 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). (1)
  • GitHub Check: Analyze (javascript-typescript)
🔇 Additional comments (2)
packages/kit/src/views/Earn/components/Overview.tsx (1)

214-316: Overview data flow and refresh wiring look consistent

Account/earn state lookup, rebate fetch, and refresh logic (including portfolio refresh via the event bus) are wired cleanly and respect loading flags. The conditional shouldShowReferralBonus prevents rendering the popover until data is ready.

No change needed here.

Also applies to: 318-429

packages/kit/src/views/Earn/EarnHome.tsx (1)

333-359: Provider wiring and native navigation redirection look coherent

EarnHomeWithProvider now cleanly threads defaultTab via overrideDefaultTab, and uses the earn Jotai store explicitly. The native redirect hook (useNavigateToNativeEarnPage) also passes the current tab param through to Discovery, which keeps tab state consistent across surfaces.

No changes needed here.

Also applies to: 361-399

Comment on lines +62 to +88
const itemRender = useCallback(
({
children,
key,
needDivider,
}: {
children: React.ReactNode;
key: string | number;
needDivider?: boolean;
}) => {
return (
<>
<ListItem
my="$2"
key={key}
ai="center"
jc="space-between"
borderWidth="$0"
>
{children}
</ListItem>
{needDivider ? <Divider mx="$5" my="$2.5" /> : 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

Move the list key to the top‑level element returned from map

itemRender returns a fragment (<>...</>) but applies key to the inner ListItem. React expects the key on the top‑level element returned by .map, so this will trigger key warnings.

Wrap the row + divider in a single container and put key there, e.g.:

-  const itemRender = useCallback(
-    ({
-      children,
-      key,
-      needDivider,
-    }: {
-      children: React.ReactNode;
-      key: string | number;
-      needDivider?: boolean;
-    }) => {
-      return (
-        <>
-          <ListItem
-            my="$2"
-            key={key}
-            ai="center"
-            jc="space-between"
-            borderWidth="$0"
-          >
-            {children}
-          </ListItem>
-          {needDivider ? <Divider mx="$5" my="$2.5" /> : null}
-        </>
-      );
-    },
-    [],
-  );
+  const itemRender = useCallback(
+    ({
+      children,
+      key,
+      needDivider,
+    }: {
+      children: React.ReactNode;
+      key: string | number;
+      needDivider?: boolean;
+    }) => (
+      <YStack key={key}>
+        <ListItem
+          my="$2"
+          ai="center"
+          jc="space-between"
+          borderWidth="$0"
+        >
+          {children}
+        </ListItem>
+        {needDivider ? <Divider mx="$5" my="$2.5" /> : null}
+      </YStack>
+    ),
+    [],
+  );

This removes the React key warning and keeps the layout the same.

📝 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 itemRender = useCallback(
({
children,
key,
needDivider,
}: {
children: React.ReactNode;
key: string | number;
needDivider?: boolean;
}) => {
return (
<>
<ListItem
my="$2"
key={key}
ai="center"
jc="space-between"
borderWidth="$0"
>
{children}
</ListItem>
{needDivider ? <Divider mx="$5" my="$2.5" /> : null}
</>
);
},
[],
);
const itemRender = useCallback(
({
children,
key,
needDivider,
}: {
children: React.ReactNode;
key: string | number;
needDivider?: boolean;
}) => (
<YStack key={key}>
<ListItem
my="$2"
ai="center"
jc="space-between"
borderWidth="$0"
>
{children}
</ListItem>
{needDivider ? <Divider mx="$5" my="$2.5" /> : null}
</YStack>
),
[],
);
🤖 Prompt for AI Agents
In packages/kit/src/views/Earn/components/Overview.tsx around lines 62–88, the
returned fragment currently applies the key to the inner ListItem which triggers
React key warnings because the top-level element returned from map must carry
the key; replace the fragment with a single container element (e.g., a
Box/Stack/View or a keyed React.Fragment long form) that wraps the ListItem and
the conditional Divider, move the key prop from ListItem to that container, and
leave the Divider rendering logic unchanged so layout is preserved.

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.

10 participants