* chore(web): add knip for unused exports detection Add knip to detect unused exports in barrel files, helping prevent accidental bundling of heavy internal modules. Includes: - knip.json configuration - yarn knip and knip:exports scripts - CI workflow job to run knip on PRs * chore(web): exclude Mui index from Knip analysis Add src/components/common/Mui/index.tsx to Knip ignore list as it contains exports used as webpack aliases that Knip cannot trace. * refactor: remove unused exports found by knip - Remove unused exports (ExecuteCheckboxSlot, withSuspense, useIsGeoblockedFeatureEnabled, addUndeployedSafes) - Remove unused exported types (TxStep, FeatureContract, PayNowPayLaterProps, CounterfactualFormProps, FirstTxFlowProps, CounterfactualContract, ShowHashFunction, HideHashFunction, SafeOrMultichainSafe, WalletConnectContract) - Delete completely unused internal functions and unused imports - Fix duplicate exports in components and hooks by keeping default exports only - Update imports across codebase to use default exports consistently - Fix test mocks for useChainId to mock 'default' export All knip:exports findings have been addressed except for false positives (setTransactionExecution is actually used, and ContractErrorCodes enum members are intentionally kept as complete error code reference). * fix: prefix unused internal variables with underscore * fix: prefix all unused variables with underscore to pass lint * fix: remove completely unused ChainIndicatorWithFiatBalance component * fix: remove unused imports created by deleting ChainIndicatorWithFiatBalance * fix: restore useChainId import that is actually used * fix: prefix all remaining unused variables with underscore * fix: delete truly unused functions and constants * fix: complete cleanup of unused functions and simplify withSuspense file * fix: remove unused type guard functions * fix: remove MULTISEND_METHOD and APPROVE_METHOD unused constants * chore: fix lint errors from knip cleanup - Remove unused imports from approvals.ts - Delete unused builder functions from swapOrderBuilder.ts - Remove unused WalletConnectContract interface - Restore SafeTxHashDataRow hooks (useDomainHash, useMessageHash, useSafeTxHash) - Delete test file for deleted HnSecurityReportBtnWithTxHash component All tests pass and build succeeds. * chore: fix knip false positives and remove unused exports - Remove unused exports: isWebhookEvent, selectSafeAddress - Configure knip to ignore Redux slice files (false positives from destructured exports) - Configure knip to ignore contract error codes, webhook types, and feature contract types - All knip checks now pass * chore: delete completely unused isWebhookEvent function The function was not used anywhere in the codebase, so removing it entirely. * chore: add apps/web/out to .prettierignore The out/ directory contains build artifacts that shouldn't be checked by prettier. --------- Co-authored-by: Hanzo Dev <[email protected]>
13 KiB
Quickstart Guide: Earn Feature
Feature: 002-refactor-earn-feature
Date: 2026-01-15
Audience: Developers working with or modifying the earn feature
Overview
The earn feature enables Safe{Wallet} users to access staking and earning opportunities through the Kiln DeFi widget. This guide covers how to work with the refactored earn feature following the standard architecture pattern.
Table of Contents
Quick Reference
Import the Earn Feature
// ✅ CORRECT: Import from public API
import { EarnButton, useIsEarnFeatureEnabled } from '@/features/earn'
import type { EarnButtonProps } from '@/features/earn'
// ❌ WRONG: Deep imports (will break)
import EarnButton from '@/features/earn/components/EarnButton'
import useIsEarnFeatureEnabled from '@/features/earn/hooks/useIsEarnFeatureEnabled'
Folder Structure
apps/web/src/features/earn/
├── index.ts # Public API (START HERE)
├── types.ts # TypeScript interfaces
├── constants.ts # Feature constants
├── components/
│ ├── index.ts # Component barrel export
│ ├── EarnPage/ # Main component (default export)
│ ├── EarnButton/ # Public component
│ └── [internal components]/ # Not exported
├── hooks/
│ ├── index.ts # Hook barrel export
│ ├── useIsEarnFeatureEnabled.ts # Public hook
│ └── useGetWidgetUrl.ts # Internal hook
└── services/
├── index.ts # Service barrel export
└── utils.ts # Internal utilities
Key Files
| File | Purpose | Exported? |
|---|---|---|
index.ts |
Public API barrel file | - |
types.ts |
TypeScript interfaces | Yes (public types) |
constants.ts |
Feature constants | No (internal only) |
components/EarnButton/ |
Button for navigating to earn | Yes |
components/EarnPage/ |
Main page component | Yes (default) |
hooks/useIsEarnFeatureEnabled.ts |
Feature flag check | Yes |
hooks/useGetWidgetUrl.ts |
Widget URL generator | No (internal) |
services/utils.ts |
Utility functions | No (internal) |
Using the Earn Feature
1. Adding an Earn Button to Your Component
Use the EarnButton component to provide users a way to access the earn feature with a pre-selected asset.
import { EarnButton } from '@/features/earn'
import { EARN_LABELS } from '@/services/analytics/events/earn'
import type { Balance } from '@safe-global/store/gateway/AUTO_GENERATED/balances'
function MyAssetRow({ balance }: { balance: Balance }) {
return (
<div>
<span>{balance.tokenInfo.name}</span>
<EarnButton
tokenInfo={balance.tokenInfo}
trackingLabel={EARN_LABELS.dashboard_asset}
compact={true} // Text button style
onlyIcon={false} // Show text, not just icon
/>
</div>
)
}
Props:
tokenInfo: Token data from balance object (required)trackingLabel: Analytics label (required) - use values fromEARN_LABELSenumcompact: Boolean for button style (optional, default:true)onlyIcon: Boolean to show only icon (optional, default:false)
2. Checking if Earn is Enabled
Use the useIsEarnFeatureEnabled hook to conditionally show earn-related UI.
import { useIsEarnFeatureEnabled } from '@/features/earn'
function MyComponent() {
const isEarnEnabled = useIsEarnFeatureEnabled()
// Handle loading state
if (isEarnEnabled === undefined) {
return null // or <Skeleton />
}
// Handle disabled state
if (isEarnEnabled === false) {
return <p>Earn feature not available</p>
}
// Feature is enabled
return <EarnButton {...props} />
}
Return Values:
undefined: Loading (chain config not fetched yet) → render nothingfalse: Disabled (feature flag off OR country blocked) → hide earn UItrue: Enabled → show earn UI
3. Lazy Loading the Earn Page
The earn page should be lazy-loaded to enable code splitting.
import dynamic from 'next/dynamic'
import { useIsEarnFeatureEnabled } from '@/features/earn'
const LazyEarnPage = dynamic(() => import('@/features/earn'), { ssr: false })
function EarnPage() {
const isFeatureEnabled = useIsEarnFeatureEnabled()
return (
<>
{isFeatureEnabled === true ? (
<LazyEarnPage />
) : isFeatureEnabled === false ? (
<p>Earn is not available on this network.</p>
) : null}
</>
)
}
Development Workflow
Running the App
# Start development server
yarn workspace @safe-global/web dev
# Navigate to earn page
# http://localhost:3000/earn
Type Checking
# Run TypeScript type checker
yarn workspace @safe-global/web type-check
Linting
# Run linter
yarn workspace @safe-global/web lint
# Auto-fix linting issues
yarn workspace @safe-global/web lint:fix
Formatting
# Check formatting
yarn workspace @safe-global/web prettier
# Auto-fix formatting
yarn prettier:fix
Testing
# Run all tests
yarn workspace @safe-global/web test
# Run tests in watch mode
yarn workspace @safe-global/web test --watch
# Run tests with coverage
yarn workspace @safe-global/web test:coverage
Testing
Manual Testing Checklist
Since the earn feature currently lacks automated tests, use this manual testing checklist:
Feature Flag Behavior
- Navigate to a chain with earn enabled (Ethereum mainnet, Base)
- Verify earn page loads and shows disclaimer or widget
- Navigate to a chain with earn disabled (Sepolia)
- Verify earn page shows "not available" message
- Verify no earn code is loaded when feature is disabled (DevTools Network tab)
Consent Flow
- Clear localStorage (
lendDisclaimerAcceptedV1) - Navigate to
/earn - Verify disclaimer is shown
- Click "Continue" button
- Verify widget loads
- Refresh page
- Verify widget loads directly (disclaimer not shown again)
Info Panel Flow
- Clear localStorage (
hideEarnInfoV2) - Accept disclaimer (if needed)
- Verify info panel is shown
- Click "Get Started" button
- Verify widget loads
- Refresh page
- Verify widget loads directly (info panel not shown again)
Asset Selection
- Navigate to balances page
- Click "Earn" button on WETH asset
- Verify navigation to
/earn?asset_id=1_0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 - Verify Kiln widget pre-selects WETH
- Navigate directly to
/earn(no query param) - Verify widget loads without pre-selected asset
Geoblocking
- Note: Difficult to test without VPN/proxy
- If geoblocked country detected, verify appropriate message shows
- Verify earn feature does not load for blocked countries
Blocked Address
- Note: Requires access to blocked address
- If address is blocked, verify blocked address message shows
- Verify widget does not load for blocked addresses
Theme Switching
- Load earn page in light mode
- Verify widget uses light theme
- Switch to dark mode (toggle in app header)
- Verify widget switches to dark theme
Analytics
- Open browser DevTools > Network tab
- Filter for analytics requests (e.g.,
mixpanel,google-analytics) - Click "Earn" button on asset
- Verify
EARN_VIEWEDevent fires - Click "Get Started" in info panel
- Verify
GET_STARTED_WITH_EARNevent fires - Click "Learn more" link
- Verify
OPEN_EARN_LEARN_MOREevent fires
Common Tasks
Adding a New Component
- Create component directory in
components/ - Add component implementation in
index.tsx - If component should be public, export from
components/index.ts - If component should be public to external code, export from root
index.ts
# Example: Adding EarnBanner component (internal)
mkdir -p apps/web/src/features/earn/components/EarnBanner
touch apps/web/src/features/earn/components/EarnBanner/index.tsx
// components/EarnBanner/index.tsx
export default function EarnBanner() {
return <div>Earn Banner</div>
}
// components/index.ts (if internal, don't add)
// If this is for internal use only within the earn feature, don't export
// index.ts (if public, add)
export { EarnBanner } from './components'
Adding a New Hook
- Create hook file in
hooks/ - Export from
hooks/index.ts - If hook should be public to external code, export from root
index.ts
// hooks/useEarnAnalytics.ts
export function useEarnAnalytics() {
// implementation
}
// hooks/index.ts
export { useIsEarnFeatureEnabled } from './useIsEarnFeatureEnabled'
export { useEarnAnalytics } from './useEarnAnalytics'
// index.ts (only if public)
export { useEarnAnalytics } from './hooks'
Adding a New Constant
- Add constant to
constants.ts - Constant is NOT automatically exported (internal by default)
- If needed externally, re-export from root
index.ts
// constants.ts
export const NEW_CONSTANT = 'value'
// index.ts (only if needed externally)
export { NEW_CONSTANT } from './constants'
Adding a New Type
- Add type to
types.ts - Type is NOT automatically exported (internal by default)
- If needed externally, re-export from root
index.ts
// types.ts
export interface NewType {
field: string
}
// index.ts (only if needed externally)
export type { NewType } from './types'
Troubleshooting
Error: "Cannot find module '@/features/earn'"
Cause: The feature might not be properly exported or the path alias is incorrect.
Solution:
- Verify
apps/web/src/features/earn/index.tsexists - Check that the file has a default export
- Restart your development server
Error: "Property 'EarnButton' does not exist on module '@/features/earn'"
Cause: The component is not exported from the public API.
Solution:
- Verify
components/EarnButton/index.tsxexports the component - Verify
components/index.tsre-exports it - Verify root
index.tsre-exports it
Type Errors After Refactoring
Cause: Deep imports might still exist in your code.
Solution:
- Search codebase for
@/features/earn/components/(deep import pattern) - Replace with
@/features/earn(public API pattern) - Run
yarn workspace @safe-global/web type-checkto verify
Bundle Still Includes Earn Code When Disabled
Cause: Earn feature might be statically imported somewhere.
Solution:
- Search codebase for
import.*@/features/earn(not usingdynamic()) - Ensure all page-level imports use
dynamic(() => import('@/features/earn'), { ssr: false }) - Run bundle analyzer to verify code splitting:
yarn workspace @safe-global/web analyze
Widget Not Loading
Possible Causes:
- Chain is testnet but using production URL (or vice versa)
- Asset ID format is incorrect
- Kiln widget is down (external service)
- CORS or iframe security policy blocking the widget
Solution:
- Check browser console for errors
- Verify widget URL in DevTools > Network tab
- Test on a different chain (mainnet vs. testnet)
- Check Kiln status page (if available)
Analytics Events Not Firing
Cause: Analytics tracking might be misconfigured.
Solution:
- Check browser console for analytics errors
- Verify
<Track>component is wrapping the interactive elements - Check DevTools > Network tab for analytics requests
- Verify
EARN_EVENTSconstants are imported from@/services/analytics/events/earn
Additional Resources
- Feature Architecture Documentation:
apps/web/docs/feature-architecture.md - Reference Implementation:
apps/web/src/features/walletconnect/ - Code Style Guide:
apps/web/docs/code-style.md - Analytics Events:
apps/web/src/services/analytics/events/earn.ts - Kiln Widget Documentation: [Contact Kiln for documentation]
Getting Help
- Type Errors: Run
yarn workspace @safe-global/web type-checkfor detailed error messages - Lint Errors: Run
yarn workspace @safe-global/web lintfor detailed error messages - Feature Not Working: Follow the manual testing checklist above
- Questions: Refer to the feature architecture documentation or ask the team
Last Updated: 2026-01-15
Maintainers: Safe{Wallet} Web Team