12 KiB
Research: Ledger Feature Architecture Refactor
Feature: 002-ledger-refactor
Date: 2026-01-15
Purpose: Document technical decisions and patterns for refactoring ledger feature
Research Tasks Completed
Task 1: Analyze Walletconnect Reference Implementation
Research Question: What is the exact pattern used in the walletconnect feature that serves as the reference implementation?
Findings:
-
Directory Structure:
components/folder with index.ts barrel and subdirectories for each componenthooks/folder with index.ts barrel and individual hook filesservices/folder with index.ts barrel and service implementationsstore/folder with index.ts barrel and Redux/store slicestypes.tsfor all TypeScript interfacesconstants.tsfor feature constantsindex.tsas public API with lazy-loaded default export
-
Lazy Loading Pattern:
const WalletConnectWidget = dynamic( () => import('./components/WalletConnectUi').then((mod) => ({ default: mod.default })), { ssr: false }, ) export default WalletConnectWidget -
Public API Exports:
- Types exported as
export type { ... } - Enums exported as
export { ... } - Hooks exported from hooks barrel
- Store functions exported from store barrel
- Constants exported selectively
- Context providers exported from components
- Types exported as
-
Testing Pattern:
- Tests colocated with components (
__tests__/subdirectories) - Hook tests in
hooks/__tests__/ - Service tests in
services/__tests__/
- Tests colocated with components (
Decision: Follow walletconnect pattern exactly for consistency.
Rationale:
- Proven pattern already in production
- Other features will follow this pattern
- ESLint rules already configured for this structure
- Developers are familiar with this organization
Task 2: ExternalStore State Management Pattern
Research Question: Should we keep ExternalStore or migrate to Redux for consistency?
Findings:
-
Current Implementation:
- Uses
ExternalStore<string | undefined>from@safe-global/utils - Simple state: hash string or undefined
- Two functions:
showLedgerHashComparison(hash)andhideLedgerHashComparison() - Component uses
ledgerHashStore.useStore()hook
- Uses
-
Alternative Redux Approach:
- Would require creating slice, actions, selectors
- More boilerplate for simple boolean+string state
- Walletconnect uses BOTH ExternalStore (
wcPopupStore) AND Redux (wcChainSwitchSlice)
-
ExternalStore Benefits:
- Lightweight (no Redux overhead)
- Already works perfectly
- Similar to React's
useSyncExternalStore - Appropriate for UI-only state (dialog visibility)
- No persistence needed
- No complex state transformations
Decision: Keep ExternalStore pattern, do NOT migrate to Redux.
Rationale:
- Spec explicitly states "preserve ExternalStore pattern (no Redux migration needed)"
- State is simple UI state (dialog open/closed with hash)
- Redux would be over-engineering for this use case
- Walletconnect reference also uses ExternalStore for similar UI state
- Refactoring goal is structure, not state management migration
Alternatives Considered:
- Redux slice: Rejected (too heavy for simple UI state)
- React Context: Rejected (ExternalStore already provides subscribe pattern)
- useState in parent: Rejected (state needs to be triggerable from service layer)
Task 3: Lazy Loading Implementation Best Practices
Research Question: What is the correct Next.js dynamic import pattern for feature components?
Findings:
-
Next.js Dynamic Import Options:
dynamic(loader, options)loader: Function that returns promise of componentoptions.ssr: Boolean (false for browser-only)options.loading: Optional loading componentoptions.suspense: Optional Suspense integration
-
Current Ledger Usage:
- TxFlow.tsx imports:
import LedgerHashComparison from '@/features/ledger' - Ledger index.ts exports:
export { default } from './LedgerHashComparison' - No lazy loading currently (static import)
- TxFlow.tsx imports:
-
Ledger-Module Service Dynamic Import:
- Line 166:
const { showLedgerHashComparison, hideLedgerHashComparison } = await import('@/features/ledger/store') - This is ALREADY dynamic (imports functions at call time)
- Functions are small and tree-shakeable
- Line 166:
-
Browser-Only Requirements:
- Uses Material-UI Dialog (requires window.document)
- Uses ExternalStore (subscribes to events)
- No SSR needed for dialog overlay
Decision: Use dynamic() with { ssr: false } for component, keep function dynamic imports.
Pattern:
// index.ts
import dynamic from 'next/dynamic'
export type {} from /* types */ './types'
export { showLedgerHashComparison, hideLedgerHashComparison } from './store'
const LedgerHashComparison = dynamic(
() => import('./components/LedgerHashComparison').then((mod) => ({ default: mod.LedgerHashComparison })),
{ ssr: false },
)
export default LedgerHashComparison
Rationale:
- Component is browser-only (Dialog, ExternalStore)
- Functions can remain in separate export (ledger-module.ts dynamic import is intentional)
- No loading state needed (dialog appears on-demand)
- Matches walletconnect pattern
Alternatives Considered:
- React.lazy: Rejected (Next.js uses dynamic for better SSR handling)
- Static import: Rejected (defeats code splitting goal)
- Suspense loading: Rejected (unnecessary for on-demand dialog)
Task 4: Type Extraction Strategy
Research Question: What types need to be extracted to types.ts?
Findings:
Current Type Usage:
-
LedgerHashComparison.tsx:
- No exported types
- Props: none (reads from store internally)
- Local variables:
hash: string | undefined,open: boolean
-
store.ts:
ExternalStore<string | undefined>(from utils)- Function signatures implicitly typed
-
External consumers:
- ledger-module.ts: Imports functions (no types needed)
- TxFlow.tsx: Imports component (React.ComponentType)
Type Needs Analysis:
- Dialog doesn't accept props (self-contained)
- Store state is
string | undefined - Functions are simple (
(hash: string) => void,() => void) - No complex domain types needed
Decision: Create minimal types.ts with public types for documentation.
Types to Define:
// types.ts
/**
* Transaction hash value (0x-prefixed hex string)
*/
export type TransactionHash = string
/**
* Store state: transaction hash to display, or undefined when dialog is hidden
*/
export type LedgerHashState = TransactionHash | undefined
/**
* Function to show the hash comparison dialog
*/
export type ShowHashFunction = (hash: TransactionHash) => void
/**
* Function to hide the hash comparison dialog
*/
export type HideHashFunction = () => void
Rationale:
- Provides type documentation for consumers
- Makes intent explicit (hash is transaction hash)
- Enables future type narrowing if needed
- Follows walletconnect pattern of explicit types
Alternatives Considered:
- No types.ts: Rejected (violates standard pattern)
- Component props interface: Rejected (component has no props)
- Complex state types: Rejected (state is intentionally simple)
Task 5: Constants Identification
Research Question: Are there any constants that should be extracted to constants.ts?
Findings:
Current Code Scan:
-
LedgerHashComparison.tsx:
- Dialog title:
"Compare transaction hash"(hardcoded) - Alert message:
"Compare this hash with the one displayed on your Ledger device..."(hardcoded) - Button text:
"Close"(hardcoded) - maxWidth:
"sm"(MUI size) - Paper maxWidth:
'180px'(specific styling) - HexEncodedData limit:
9999(essentially "show all")
- Dialog title:
-
store.ts:
- Initial state:
undefined
- Initial state:
-
No feature flags (always enabled)
-
No API endpoints
-
No magic numbers that affect behavior
Decision: Create minimal constants.ts for documentation, extract UI strings.
Constants to Define:
// constants.ts
/**
* Dialog configuration
*/
export const DIALOG_MAX_WIDTH = 'sm' as const
export const HASH_DISPLAY_WIDTH = '180px'
export const HASH_DISPLAY_LIMIT = 9999
/**
* UI text constants
*/
export const DIALOG_TITLE = 'Compare transaction hash'
export const DIALOG_DESCRIPTION =
'Compare this hash with the one displayed on your Ledger device before confirming the transaction.'
export const CLOSE_BUTTON_TEXT = 'Close'
Rationale:
- Makes text easily changeable for i18n future work
- Documents magic numbers (9999 = show all bytes)
- Improves testability (can assert on constants)
- Follows principle of no magic values in components
Alternatives Considered:
- No constants.ts: Rejected (violates standard pattern, strings should be extractable)
- Keep strings inline: Rejected (future i18n would require changes)
- Feature flag constant: Rejected (feature not behind flag)
Task 6: Testing Strategy
Research Question: What tests are needed for the refactored feature?
Findings:
Current Test Coverage: NONE (no existing tests for ledger feature)
Test Categories Needed:
-
Store Tests (
store/ledgerHashStore.test.ts):showLedgerHashComparison(hash)updates store statehideLedgerHashComparison()clears store state- Multiple rapid calls use latest hash value
- Initial state is undefined
-
Component Tests (
components/LedgerHashComparison/index.test.tsx):- Renders null when store state is undefined
- Renders dialog when store state has hash
- Displays hash value correctly
- Calls hide function when close button clicked
- Calls hide function when dialog onClose triggered
- Copies hash to clipboard when copy button clicked
-
Integration Test (in existing transaction flow tests):
- Verify dialog appears during Ledger signing flow
- Not needed as separate test (covered by manual testing)
Testing Tools:
- Jest (already configured)
- React Testing Library (already configured)
- @testing-library/user-event (for interactions)
- No MSW needed (no network calls)
Decision: Add unit tests for store and component, skip integration test.
Test Structure:
// store/ledgerHashStore.test.ts
describe('ledgerHashStore', () => {
it('should start with undefined state')
it('should update state when showLedgerHashComparison called')
it('should clear state when hideLedgerHashComparison called')
it('should use latest hash when called multiple times')
})
// components/LedgerHashComparison/index.test.tsx
describe('LedgerHashComparison', () => {
it('should not render when no hash present')
it('should render dialog when hash present')
it('should display transaction hash')
it('should close dialog when close button clicked')
it('should close dialog when backdrop clicked')
})
Rationale:
- Constitution requires unit tests for all logic
- Store functions are testable in isolation
- Component behavior is straightforward
- No complex state interactions
- Existing e2e tests cover integration
Alternatives Considered:
- Skip tests: Rejected (violates constitution)
- Only integration tests: Rejected (doesn't test units)
- Snapshot tests: Rejected (too brittle for dialogs)
Research Summary
Technology Stack Confirmed
- Next.js 14+ dynamic imports for lazy loading
- React 18+ for component structure
- TypeScript 5.x strict mode
- Material-UI v5 for Dialog component
- ExternalStore from @safe-global/utils for state
- Jest + React Testing Library for testing
Key Architectural Decisions
| Decision | Choice | Alternative Rejected |
|---|---|---|
| State Management | Keep ExternalStore | Redux (too heavy) |
| Lazy Loading | dynamic() with ssr: false | Static import (no code splitting) |
| Type Extraction | Minimal documentation types | No types (violates pattern) |
| Constants | Extract UI strings | Inline strings (not i18n-ready) |
| Testing | Unit tests for store + component | No tests (violates constitution) |
| Folder Structure | Match walletconnect pattern | Custom structure (inconsistent) |
No Unresolved Questions
All technical decisions are clear and documented. Ready for Phase 1 (data model and contracts).