* 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]>
16 KiB
Data Model: Refactor Earn Feature
Feature: 002-refactor-earn-feature
Date: 2026-01-15
Purpose: Define data entities, relationships, and state management for the earn feature
Overview
The earn feature is a UI-centric feature with minimal local state management. Most data flows through external systems (Kiln widget iframe, CGW API for feature flags, browser LocalStorage for user preferences). This document defines the logical data entities and their relationships within the earn feature domain.
Entities
1. Earn Consent State
Description: Tracks whether the user has accepted the disclaimer to use the Kiln earn widget.
Storage: Browser LocalStorage
Key: lendDisclaimerAcceptedV1 (stored in EARN_CONSENT_STORAGE_KEY constant)
Schema:
type EarnConsentState = boolean | undefined
// undefined = not checked yet (loading)
// false = user has not accepted
// true = user has accepted
Lifecycle:
- On first visit to
/earn, state isundefined(loading from localStorage) - Hook
useConsentresolves tofalseif no consent stored - User clicks "Continue" on disclaimer → state becomes
true, stored in localStorage - On subsequent visits, state immediately resolves to
true
Validation Rules:
- Must be a boolean value when stored
- No expiration (persists indefinitely until user clears storage)
- Feature-specific (does not affect other features)
Dependencies:
- Used by:
EarnPagecomponent to determine whether to show disclaimer or widget - Hook:
useConsent(EARN_CONSENT_STORAGE_KEY)from@/hooks/useConsent
2. Feature Flag State
Description: Determines whether the earn feature is enabled for the current chain.
Storage: CGW API (Chain Gateway) chain configuration
Key: FEATURES.EARN from @safe-global/utils/utils/chains
Schema:
type FeatureFlagState = boolean | undefined
// undefined = loading (chain config not yet fetched)
// false = feature disabled for current chain
// true = feature enabled for current chain
Lifecycle:
- On app load, chain config is fetched from CGW API
- Hook
useHasFeature(FEATURES.EARN)returnsundefinedwhile loading - Once loaded, returns
trueorfalsebased on chain config - Changes when user switches chains (re-evaluated per chain)
Validation Rules:
- Read-only (cannot be modified by client)
- Per-chain configuration (different chains have different values)
- Combines with geoblocking check in
useIsEarnFeatureEnabled
Dependencies:
- Used by:
useIsEarnFeatureEnabledhook (public API) - Used by:
earn.tsxpage to show/hide feature - Hook:
useHasFeature(FEATURES.EARN)from@/hooks/useChains
3. Geoblocking State
Description: Determines whether the user's country is blocked from accessing earn features.
Storage: Global React Context (GeoblockingContext)
Schema:
type GeoblockingState = boolean
// false = country is not blocked
// true = country is blocked (user cannot access earn)
Lifecycle:
- On app load, geolocation check is performed
- Context provider sets boolean value based on user's country
- Value is accessible throughout the app via
useContext(GeoblockingContext)
Validation Rules:
- Read-only (cannot be modified by client)
- Global state (not feature-specific, but affects earn)
- Regulatory compliance requirement
Dependencies:
- Used by:
useIsEarnFeatureEnabledhook to combine with feature flag - Context:
GeoblockingContextfrom@/components/common/GeoblockingProvider
4. Blocked Address State
Description: Determines whether the current Safe address is on a blocklist.
Storage: Determined by backend check
Schema:
type BlockedAddressState = string | null
// null = address is not blocked
// string = blocked address (shows error message)
Lifecycle:
- Hook
useBlockedAddresschecks current Safe against blocklist - Returns
nullif not blocked, or the address string if blocked - Re-evaluated when Safe address changes
Validation Rules:
- Read-only (cannot be modified by client)
- Address-specific (not chain-specific)
- Security/compliance requirement
Dependencies:
- Used by:
EarnPagecomponent to show blocked address message - Hook:
useBlockedAddress()from@/hooks/useBlockedAddress
5. Asset Selection State
Description: Pre-selects a specific asset when navigating to the earn page.
Storage: URL query parameter (asset_id)
Key: asset_id query parameter
Schema:
type AssetSelectionState = string | undefined
// undefined = no asset pre-selected
// string = asset identifier in format "{chainId}_{tokenAddress}"
// Example: "1_0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" (WETH on Ethereum)
Lifecycle:
- User clicks
EarnButtonon an asset row - Button navigates to
/earn?asset_id={chainId}_{tokenAddress} EarnViewcomponent reads query parameter- Parameter is passed to Kiln widget via
useGetWidgetUrlhook - Widget pre-selects the asset for the user
Validation Rules:
- Format:
{chainId}_{tokenAddress}(e.g., "1_0xABC...") - Chain ID must match current chain
- Token address must be valid Ethereum address
- Token must be eligible for earn (checked via
isEligibleEarnTokenutility)
Dependencies:
- Set by:
EarnButtoncomponent when clicked - Read by:
EarnViewcomponent viauseRouter().query - Passed to: Kiln widget via
useGetWidgetUrlhook - Validated by:
isEligibleEarnTokenutility inservices/utils.ts
6. Info Panel Display State
Description: Tracks whether the user has dismissed the informational panel about earn.
Storage: Browser LocalStorage
Key: hideEarnInfoV2 (stored in hideEarnInfoStorageKey constant)
Schema:
type InfoPanelState = boolean | undefined
// undefined = not checked yet (loading)
// false = info panel should be shown
// true = info panel has been dismissed, show widget directly
Lifecycle:
- On visit to
/earn, state is checked from localStorage - If
falseorundefined, showEarnInfocomponent - User clicks "Get Started" → state becomes
true, stored in localStorage - On subsequent visits, show
EarnWidgetdirectly (skip info panel)
Validation Rules:
- Boolean value when stored
- No expiration (persists indefinitely)
- User can reset by clearing localStorage
Dependencies:
- Used by:
EarnViewcomponent to determine which view to show - Hook:
useLocalStorage<boolean>(hideEarnInfoStorageKey)from@/services/local-storage/useLocalStorage
7. Widget Configuration
Description: Configuration data passed to the Kiln widget iframe.
Storage: Computed dynamically (not persisted)
Schema:
interface WidgetConfiguration {
url: string // Base URL (production or testnet)
theme: 'light' | 'dark' // Theme based on user preference
asset_id?: string // Optional pre-selected asset
}
Lifecycle:
useGetWidgetUrlhook computes widget URL- Checks if current chain is testnet → use
WIDGET_TESTNET_URL - Otherwise → use
WIDGET_PRODUCTION_URL - Appends query parameters:
theme,asset_id(if provided) - Returns complete URL for iframe
srcattribute
Validation Rules:
- URL must be from approved Kiln domains
- Theme must match user's dark mode preference
- Asset ID (if provided) must match format from Asset Selection State
Dependencies:
- Generated by:
useGetWidgetUrlhook inhooks/useGetWidgetUrl.ts - Used by:
EarnWidgetcomponent to set iframesrc - Depends on:
useDarkMode,useChains,useChainIdhooks
Relationships
┌─────────────────────────────────────────────────────────────────┐
│ User Navigation │
│ (to /earn or via EarnButton) │
└────────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ earn.tsx Page │
│ (checks Feature Flag + Geoblocking) │
└───┬─────────────────────┬─────────────────────┬─────────────────┘
│ │ │
│ FeatureFlagState │ GeoblockingState │ BlockedAddressState
│ = undefined │ = true │ ≠ null
▼ ▼ ▼
┌─────────┐ ┌──────────────┐ ┌───────────────────┐
│ Render │ │ Render │ │ Render │
│ nothing │ │ Geoblocking │ │ Blocked Address │
│ │ │ Message │ │ Message │
└─────────┘ └──────────────┘ └───────────────────┘
│
│ FeatureFlagState = true && GeoblockingState = false
▼
┌─────────────────────────────────────────────────────────────────┐
│ EarnPage Component │
│ (checks Earn Consent State) │
└───┬─────────────────────────────────────────┬───────────────────┘
│ EarnConsentState = false │ EarnConsentState = true
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ Disclaimer Component │ │ EarnView Component │
│ (user clicks "Continue") │ │ (checks Info Panel State) │
└────────────┬────────────────┘ └───┬─────────────────────────┘
│ │
│ Sets EarnConsentState │ InfoPanelState = false
│ to true ▼
│ ┌──────────────────┐
│ │ EarnInfo │
│ │ (user clicks │
│ │ "Get Started") │
│ └────────┬─────────┘
│ │
│ │ Sets InfoPanelState
│ │ to true
│ InfoPanelState = true │
└────────────┬────────────────┘
▼
┌──────────────────────────┐
│ EarnWidget │
│ (renders Kiln iframe) │
└────────────┬─────────────┘
│
│ Uses Widget Configuration
│ + Asset Selection State
▼
┌────────────────────┐
│ Kiln Widget │
│ (third-party) │
└────────────────────┘
State Management
Local Component State
The earn feature uses local component state and React hooks rather than Redux. This is appropriate because:
- Ephemeral UI state: Consent, info panel visibility are UI concerns
- No cross-feature sharing: No other features need access to earn state
- Simple state transitions: Boolean flags with straightforward logic
- External widget: Core functionality lives in Kiln iframe (external system)
No Redux Store Needed
Decision: The earn feature does NOT require a Redux store slice.
Rationale:
- State is localized to the feature
- No complex state machines or async state
- No need for time-travel debugging
- Follows principle of simplicity (Constitution Principle: avoid over-engineering)
Hooks Used for State
| Hook | Purpose | Source |
|---|---|---|
useConsent |
Manage consent state in localStorage | @/hooks/useConsent |
useLocalStorage |
Manage info panel state in localStorage | @/services/local-storage/useLocalStorage |
useHasFeature |
Read feature flag state from CGW config | @/hooks/useChains |
useContext(GeoblockingContext) |
Read geoblocking state from context | React Context |
useBlockedAddress |
Check if current address is blocked | @/hooks/useBlockedAddress |
useRouter |
Read and write URL query parameters | Next.js router |
useDarkMode |
Read user's theme preference | @/hooks/useDarkMode |
Data Flow Summary
- User navigates to
/earn→ Page checks feature flag + geoblocking - Feature enabled → Page checks consent state
- Consent not given → Show disclaimer, wait for acceptance
- Consent given → Show EarnView, check info panel state
- Info panel not dismissed → Show EarnInfo, wait for "Get Started"
- Info panel dismissed → Show EarnWidget with Kiln iframe
- Asset pre-selected (via query param) → Pass to widget URL
- Widget loads → User interacts with third-party Kiln interface
Validation Summary
| Entity | Validation | Enforced By |
|---|---|---|
| Earn Consent | Boolean only | useConsent hook |
| Feature Flag | Read-only from API | CGW API + useHasFeature |
| Geoblocking | Read-only from context | GeoblockingContext |
| Blocked Address | Read-only from hook | useBlockedAddress |
| Asset Selection | Format {chainId}_{address} |
isEligibleEarnToken utility |
| Info Panel State | Boolean only | useLocalStorage hook |
| Widget Config | URL from allowed domains | useGetWidgetUrl hook |
Conclusion
The earn feature's data model is intentionally simple. State is managed through React hooks and browser APIs (localStorage, URL parameters, Context). No Redux store is needed. The refactoring will preserve this simple state management while improving the code organization and API boundaries.