Upstream merge brings 52 commits from safe-global/safe-wallet-monorepo into the luxfi/safe-wallet@dev fork. Conflicts resolved: - yarn.lock: deleted (we are on pnpm) - apps/web/package.json: kept pnpm version + brand build scripts (build:safe / build:lux / build:hanzo) plus upstream's new generate-version.mjs step - apps/mobile/.../Signer/appKit.ts: took upstream's import pattern for shared/projectId, shared/metadata, shared/mmkvStorageAdapter Yarn sweep applied to 42 files (docs, github-actions, workflows, AGENTS, specs, codemods, scripts, READMEs). Net result: zero yarn-* command references anywhere outside node_modules. ❯ pnpm is the law in this fork. No yarn.
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
pnpm --filter @safe-global/web dev
# Navigate to earn page
# http://localhost:3000/earn
Type Checking
# Run TypeScript type checker
pnpm --filter @safe-global/web type-check
Linting
# Run linter
pnpm --filter @safe-global/web lint
# Auto-fix linting issues
pnpm --filter @safe-global/web lint:fix
Formatting
# Check formatting
pnpm --filter @safe-global/web prettier
# Auto-fix formatting
pnpm prettier:fix
Testing
# Run all tests
pnpm --filter @safe-global/web test
# Run tests in watch mode
pnpm --filter @safe-global/web test --watch
# Run tests with coverage
pnpm --filter @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
pnpm --filter @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:
pnpm --filter @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
pnpm --filter @safe-global/web type-checkfor detailed error messages - Lint Errors: Run
pnpm --filter @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