diff --git a/.gitignore b/.gitignore index 4b11217..ef52958 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,6 @@ dist smart-contract-cli routes_gen.go bin/ -mock_*.go \ No newline at end of file +mock_*.go +wallet.test +~/smart_contract_cli diff --git a/CLAUDE.md b/CLAUDE.md index 362c955..a398668 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -851,6 +851,152 @@ The component system handles rendering; Bubble Tea handles state management and - No external dependencies required (pure unit tests) - Tests: route matching, parameter extraction, navigation stack, Bubble Tea integration +### Mock Generation with mockgen + +**IMPORTANT: Always use mockgen-generated mocks for new tests. Do NOT create manual mock implementations.** + +The project uses `go.uber.org/mock/gomock` for generating mocks. Generated mocks are available for: + +- `WalletService`: `internal/contract/evm/wallet/mock_service.go` +- `Storage`: `internal/contract/evm/storage/sql/mock_storage.go` +- `SecureStorage`: `internal/storage/mock_secure.go` +- `Router`: `internal/view/mock_router.go` + +#### Managing Mockgen Directives in tools/tools.go + +**CRITICAL: Always add new mockgen directives to `tools/tools.go` when creating new interfaces.** + +The `tools/tools.go` file is the central location for all code generation directives, including mockgen. This ensures mocks are automatically regenerated when running `make generate`. + +**Pattern for Adding New Mocks:** + +```go +// In tools/tools.go, add a new go:generate directive: +//go:generate go run go.uber.org/mock/mockgen -source=../internal/path/to/interface.go -destination=../internal/path/to/mock_interface.go -package=packagename +``` + +**Example:** + +If you create a new interface at `internal/services/payment/service.go`, add this line to `tools/tools.go`: + +```go +// Payment service mocks +//go:generate go run go.uber.org/mock/mockgen -source=../internal/services/payment/service.go -destination=../internal/services/payment/mock_service.go -package=payment +``` + +**Workflow:** + +1. Create your interface file (e.g., `internal/services/payment/service.go`) +2. Add the mockgen directive to `tools/tools.go` +3. Run `make generate` to generate the mock +4. The mock will be available at the specified destination path + +**Why This Approach:** + +- **Centralization**: All generation commands in one place +- **Discoverability**: Easy to see what mocks exist and how they're generated +- **Automation**: Mocks regenerate automatically with `make generate` +- **Consistency**: Standard pattern for all mock generation +- **Version Control**: `tools/tools.go` is committed, ensuring all developers can regenerate mocks + +**Generating Mocks:** + +```bash +# Regenerate all mocks (recommended) +make generate + +# Or run go generate directly +go generate ./tools/tools.go +``` + +**Example: Using mockgen in Tests** + +```go +package mypackage_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + "go.uber.org/mock/gomock" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/wallet" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/sql" +) + +type MyTestSuite struct { + suite.Suite + ctrl *gomock.Controller + walletService *wallet.MockWalletService + storage *sql.MockStorage +} + +func TestMyTestSuite(t *testing.T) { + suite.Run(t, new(MyTestSuite)) +} + +func (s *MyTestSuite) SetupTest() { + // Create gomock controller + s.ctrl = gomock.NewController(s.T()) + + // Create mocks + s.walletService = wallet.NewMockWalletService(s.ctrl) + s.storage = sql.NewMockStorage(s.ctrl) + + // Set up expectations - examples: + + // Expect specific call with exact arguments + s.walletService.EXPECT(). + GetWallet(uint(1)). + Return(&models.EVMWallet{ID: 1, Alias: "test"}, nil) + + // Expect call with any arguments + s.storage.EXPECT(). + GetCurrentConfig(). + Return(models.EVMConfig{}, nil). + AnyTimes() // Can be called any number of times + + // Expect call multiple times + s.walletService.EXPECT(). + ValidatePrivateKey(gomock.Any()). + Return(nil). + Times(2) + + // Expect call in specific order + gomock.InOrder( + s.walletService.EXPECT().ImportPrivateKey("alias", "key").Return(wallet, nil), + s.walletService.EXPECT().GetWalletWithBalance(uint(1), "url").Return(walletWithBalance, nil), + ) +} + +func (s *MyTestSuite) TearDownTest() { + // Verify all expectations were met + s.ctrl.Finish() +} + +func (s *MyTestSuite) TestExample() { + // Test code here - mocks will verify expectations automatically +} +``` + +**Key Differences from testify/mock:** + +| testify/mock | gomock | +|--------------|---------| +| `mock.On("Method", args).Return(values)` | `EXPECT().Method(args).Return(values)` | +| `mock.AssertExpectations(t)` | `ctrl.Finish()` (in TearDownTest) | +| `mock.Anything` | `gomock.Any()` | +| `.Times(n)` | `.Times(n)` (same) | +| No built-in call ordering | `gomock.InOrder(calls...)` | +| Implicit any-times | Must specify `.AnyTimes()` explicitly | + +**Best Practices:** + +1. **Always call `ctrl.Finish()`** in `TearDownTest()` to verify expectations +2. **Use `AnyTimes()`** for setup methods that may or may not be called +3. **Use specific matchers** when possible instead of `gomock.Any()` +4. **Test one behavior per test** - avoid over-specifying expectations +5. **Order matters** - by default, gomock expects calls in the order they're specified unless using `AnyTimes()` + ## Development Notes ** Always use test suite testing structure to write tests** @@ -973,7 +1119,7 @@ func GetRoutes() []view.Route { **Generate routes:** ```bash -make generate-routes +make generate ``` This command: @@ -1403,3 +1549,174 @@ internal/log/ ├── logger_test.go # Test suite (13 tests) └── USAGE.md # Comprehensive usage guide ``` + +## EVM Wallet Page Pattern + +When implementing wallet-related pages in the `app/evm/wallet/` directory, follow these patterns to ensure consistent access to storage, secure storage, and configuration. + +### Configuration and Storage Access + +**Always use utility functions** to access storage and secure storage from shared memory: + +```go +// Get storage client +sqlStorage, err := utils.GetStorageClientFromSharedMemory(m.sharedMemory) +if err != nil { + return walletLoadedMsg{err: fmt.Errorf("failed to get storage client: %w", err)} +} + +// Get secure storage +secureStorage, _, err := utils.GetSecureStorageFromSharedMemory(m.sharedMemory) +if err != nil { + return walletLoadedMsg{err: fmt.Errorf("failed to get secure storage: %w", err)} +} +``` + +**DO NOT** manually type assert storage clients: + +```go +// BAD - Don't do this +storageClient, err := m.sharedMemory.Get(config.StorageClientKey) +sqlStorage, isValidStorage := storageClient.(sql.Storage) +if !isValidStorage { + return nil, fmt.Errorf("invalid storage client type") +} + +// GOOD - Use the utility function +sqlStorage, err := utils.GetStorageClientFromSharedMemory(m.sharedMemory) +if err != nil { + return nil, err +} +``` + +### RPC Endpoint Configuration + +**Always retrieve RPC endpoint from EVM config** stored in the database, not from shared memory or hardcoded values: + +```go +// Get the current config +config, err := sqlStorage.GetCurrentConfig() +if err != nil { + return walletLoadedMsg{err: fmt.Errorf("failed to get current config: %w", err)} +} + +// Validate endpoint exists +if config.Endpoint == nil { + return walletLoadedMsg{err: fmt.Errorf("no RPC endpoint configured. Please configure an endpoint first")} +} + +rpcEndpoint := config.Endpoint.Url +``` + +### Selected Wallet Management + +**Store and retrieve selected wallet ID from EVM config**, not shared memory: + +```go +// Retrieve selected wallet ID from config +var selectedWalletID uint +if config.SelectedWalletID != nil { + selectedWalletID = *config.SelectedWalletID +} +``` + +### EVMConfig Model Structure + +The `EVMConfig` model includes the following fields for wallet and endpoint management: + +- `SelectedWalletID *uint` - Pointer to selected wallet ID (can be nil) +- `SelectedWallet *EVMWallet` - Preloaded wallet object (use with GORM preload) +- `EndpointId *uint` - Pointer to RPC endpoint ID +- `Endpoint *EVMEndpoint` - Preloaded endpoint object with URL + +**Loading Related Data:** + +```go +// Preload wallet and endpoint relationships +config, err := sqlStorage.GetCurrentConfig() +// This automatically preloads SelectedWallet and Endpoint if IDs are set +``` + +### Best Practices + +1. **Centralized Configuration**: Always read configuration from the database (via `sqlStorage.GetCurrentConfig()`), not from shared memory +2. **Use Utility Functions**: Use `utils.GetStorageClientFromSharedMemory()` and `utils.GetSecureStorageFromSharedMemory()` instead of manual type assertions +3. **Validate Configuration**: Always check if required fields (like `Endpoint`) are present before using them +4. **Null Safety**: Use pointer checks (`if config.SelectedWalletID != nil`) when accessing optional configuration fields +5. **Error Context**: Wrap errors with context using `fmt.Errorf("context: %w", err)` for better debugging + +### Complete Examples + +**Wallet List Page** (see `app/evm/wallet/page.go:72-121`): + +```go +func (m Model) loadWallets() tea.Msg { + // Get storage client + sqlStorage, err := utils.GetStorageClientFromSharedMemory(m.sharedMemory) + if err != nil { + return walletLoadedMsg{err: err} + } + + // Get current config + config, err := sqlStorage.GetCurrentConfig() + if err != nil { + return walletLoadedMsg{err: err} + } + + // Get selected wallet ID from config + var selectedWalletID uint + if config.SelectedWalletID != nil { + selectedWalletID = *config.SelectedWalletID + } + + // Load wallets from database + wallets, err := sqlStorage.GetAllWallets() + return walletLoadedMsg{wallets: wallets, selectedID: selectedWalletID, err: err} +} +``` + +**Wallet Actions Page** (see `app/evm/wallet/actions/page.go:83-146`): + +```go +func (m Model) loadWalletBalance() tea.Msg { + // Get storage and secure storage + sqlStorage, err := utils.GetStorageClientFromSharedMemory(m.sharedMemory) + if err != nil { + return balanceLoadedMsg{err: err} + } + + secureStorage, _, err := utils.GetSecureStorageFromSharedMemory(m.sharedMemory) + if err != nil { + return balanceLoadedMsg{err: err} + } + + // Get current config with endpoint + config, err := sqlStorage.GetCurrentConfig() + if err != nil { + return balanceLoadedMsg{err: err} + } + + // Validate RPC endpoint exists + if config.Endpoint == nil { + return balanceLoadedMsg{err: fmt.Errorf("no RPC endpoint configured")} + } + + // Use endpoint URL for blockchain operations + transport, err := transport.NewHttpTransport(config.Endpoint.Url) + if err != nil { + return balanceLoadedMsg{err: err} + } + + // ... perform blockchain operations +} +``` + +### Rationale + +This pattern ensures: + +1. **Single Source of Truth**: Database is the authoritative source for all configuration +2. **Type Safety**: Utility functions handle type assertions safely +3. **Consistency**: All wallet pages follow the same pattern for accessing storage and configuration +4. **Maintainability**: Changes to storage access patterns only need to be updated in utility functions +5. **Error Handling**: Clear error context at each step for easier debugging diff --git a/app/evm/wallet/actions/page.go b/app/evm/wallet/actions/page.go new file mode 100644 index 0000000..1766e97 --- /dev/null +++ b/app/evm/wallet/actions/page.go @@ -0,0 +1,350 @@ +package actions + +import ( + "fmt" + "math/big" + "strconv" + + tea "github.com/charmbracelet/bubbletea" + "github.com/rxtech-lab/smart-contract-cli/internal/config" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/sql" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/wallet" + "github.com/rxtech-lab/smart-contract-cli/internal/log" + "github.com/rxtech-lab/smart-contract-cli/internal/storage" + "github.com/rxtech-lab/smart-contract-cli/internal/ui/component" + "github.com/rxtech-lab/smart-contract-cli/internal/utils" + "github.com/rxtech-lab/smart-contract-cli/internal/view" +) + +var logger, _ = log.NewFileLogger("./logs/evm/wallet/actions.log") + +type actionOption struct { + label string + description string + route string + action func(m *Model) tea.Cmd +} + +type Model struct { + router view.Router + sharedMemory storage.SharedMemory + walletService wallet.WalletService + + walletID uint + wallet *wallet.WalletWithBalance + selectedWalletID uint + selectedIndex int + options []actionOption + + errorMsg string +} + +func NewPage(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(router, sharedMemory, nil) +} + +// NewPageWithService creates a new actions page with an optional wallet service (for testing). +func NewPageWithService(router view.Router, sharedMemory storage.SharedMemory, walletService wallet.WalletService) view.View { + return Model{ + router: router, + sharedMemory: sharedMemory, + walletService: walletService, + selectedIndex: 0, + } +} + +func (m Model) Init() tea.Cmd { + logger.Info("Actions page Init() called") + return m.loadWallet +} + +func (m Model) createWalletService() (wallet.WalletService, error) { + storageClient, err := m.sharedMemory.Get(config.StorageClientKey) + if err != nil || storageClient == nil { + logger.Error("Failed to get storage client from shared memory: %v", err) + return nil, fmt.Errorf("storage client not initialized") + } + + sqlStorage, isValidStorage := storageClient.(sql.Storage) + if !isValidStorage { + logger.Error("Invalid storage client type") + return nil, fmt.Errorf("invalid storage client type") + } + + secureStorage, _, err := utils.GetSecureStorageFromSharedMemory(m.sharedMemory) + if err != nil { + logger.Error("Failed to get secure storage from shared memory: %v", err) + return nil, fmt.Errorf("failed to get secure storage from shared memory: %w", err) + } + + return wallet.NewWalletService(sqlStorage, secureStorage), nil +} + +func (m Model) loadWallet() tea.Msg { + // Get wallet ID from query params + walletIDStr := m.router.GetQueryParam("id") + logger.Info("Loading wallet with ID: %s", walletIDStr) + if walletIDStr == "" { + logger.Error("Wallet ID not provided in query params") + return walletLoadedMsg{err: fmt.Errorf("wallet ID not provided")} + } + + walletID, err := strconv.ParseUint(walletIDStr, 10, 32) + if err != nil { + return walletLoadedMsg{err: fmt.Errorf("invalid wallet ID: %w", err)} + } + + // Use injected wallet service if available (for testing) + walletService := m.walletService + if walletService == nil { + svc, err := m.createWalletService() + if err != nil { + return walletLoadedMsg{err: err} + } + walletService = svc + } + + // Get RPC endpoint from database + sqlStorage, err := utils.GetStorageClientFromSharedMemory(m.sharedMemory) + if err != nil { + logger.Error("Failed to get storage client from shared memory: %v", err) + return walletLoadedMsg{err: fmt.Errorf("failed to get storage client from shared memory: %w", err)} + } + // Get the current config + config, err := sqlStorage.GetCurrentConfig() + if err != nil { + logger.Error("Failed to get current config: %v", err) + return walletLoadedMsg{err: fmt.Errorf("failed to get current config: %w", err)} + } + if config.Endpoint == nil { + logger.Error("No RPC endpoint configured") + return walletLoadedMsg{err: fmt.Errorf("no RPC endpoint configured. Please configure an endpoint first")} + } + rpcEndpoint := config.Endpoint.Url + logger.Info("Using RPC endpoint: %s", rpcEndpoint) + + // Get wallet with balance + logger.Info("Fetching wallet %d with balance from %s", walletID, rpcEndpoint) + walletData, err := walletService.GetWalletWithBalance(uint(walletID), rpcEndpoint) + if err != nil { + logger.Error("Failed to get wallet with balance: %v", err) + return walletLoadedMsg{err: fmt.Errorf("failed to load wallet: %w", err)} + } + logger.Info("Successfully loaded wallet: %s", walletData.Wallet.Alias) + + // Get selected wallet ID from shared memory + selectedWalletID := config.SelectedWalletID + if selectedWalletID == nil { + return walletLoadedMsg{err: fmt.Errorf("no selected wallet ID found in config")} + } + + return walletLoadedMsg{ + walletID: uint(walletID), + wallet: walletData, + walletService: walletService, + selectedWalletID: *config.SelectedWalletID, + } +} + +type walletLoadedMsg struct { + walletID uint + wallet *wallet.WalletWithBalance + walletService wallet.WalletService + selectedWalletID uint + err error +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case walletLoadedMsg: + logger.Info("Received walletLoadedMsg") + if msg.err != nil { + logger.Error("walletLoadedMsg contains error: %v", msg.err) + m.errorMsg = msg.err.Error() + return m, nil + } + + logger.Info("Setting wallet data, loading=false") + m.walletID = msg.walletID + m.wallet = msg.wallet + m.walletService = msg.walletService + m.selectedWalletID = msg.selectedWalletID + + // Build action options based on whether this is the selected wallet + isSelected := m.walletID == m.selectedWalletID + m.options = m.buildActionOptions(isSelected) + logger.Info("Wallet loaded successfully, options count: %d", len(m.options)) + + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if m.selectedIndex > 0 { + m.selectedIndex-- + } + + case "down", "j": + if m.selectedIndex < len(m.options)-1 { + m.selectedIndex++ + } + + case "enter": + if len(m.options) > 0 { + option := m.options[m.selectedIndex] + if option.action != nil { + return m, option.action(&m) + } else if option.route != "" { + return m, func() tea.Msg { + _ = m.router.NavigateTo(option.route, map[string]string{ + "id": strconv.FormatUint(uint64(m.walletID), 10), + }) + return nil + } + } + } + + case "ctrl+c": + // Handle quit for testing purposes + return m, tea.Quit + } + } + + return m, nil +} + +func (m Model) buildActionOptions(isSelected bool) []actionOption { + options := []actionOption{} + + // If not selected, add "Select as active wallet" option first + if !isSelected { + options = append(options, actionOption{ + label: "Select as active wallet", + description: "Make this the current active wallet for transactions", + route: "/evm/wallet/select", + }) + } + + // Common options for all wallets + options = append(options, []actionOption{ + { + label: "View details", + description: "View full wallet information and transaction history", + route: "/evm/wallet/details", + }, + { + label: "Update wallet", + description: "Update wallet alias or private key", + route: "/evm/wallet/update", + }, + { + label: "Delete wallet", + description: "Remove this wallet from the system", + route: "/evm/wallet/delete", + }, + }...) + + return options +} + +func (m Model) Help() (string, view.HelpDisplayOption) { + return "↑/k: up • ↓/j: down • enter: confirm • esc: cancel", view.HelpDisplayOptionAppend +} + +func (m Model) View() string { + if m.errorMsg != "" { + return component.VStackC( + component.T("Wallet Actions").Bold(true).Primary(), + component.SpacerV(1), + component.T("Error: "+m.errorMsg).Error(), + component.SpacerV(1), + component.T("Press 'esc' to go back").Muted(), + ).Render() + } + + if m.wallet == nil { + return component.VStackC( + component.T("Wallet Actions").Bold(true).Primary(), + component.SpacerV(1), + component.T("Wallet not found").Error(), + ).Render() + } + + // Format balance + balanceStr := "unavailable ⚠" + if m.wallet.Error == nil && m.wallet.Balance != nil { + ethValue := new(big.Float).Quo( + new(big.Float).SetInt(m.wallet.Balance), + new(big.Float).SetInt(big.NewInt(1e18)), + ) + balanceStr = fmt.Sprintf("%.4f ETH", ethValue) + } + + // Title with selected indicator + title := "Wallet Actions - " + m.wallet.Wallet.Alias + if m.walletID == m.selectedWalletID { + title = title + " ★" + } + + // Build header + header := component.VStackC( + component.T(title).Bold(true).Primary(), + component.SpacerV(1), + component.T("Address: "+m.wallet.Wallet.Address).Muted(), + component.T("Balance: "+balanceStr).Muted(), + component.SpacerV(1), + ) + + // Add note for selected wallet + if m.walletID == m.selectedWalletID { + header = component.VStackC( + header, + component.T("This is your currently selected wallet.").Muted(), + component.SpacerV(1), + ) + } + + // Build action menu + header = component.VStackC( + header, + component.T("What would you like to do?").Bold(true), + component.SpacerV(1), + ) + + // Build options list + optionComponents := make([]component.Component, 0) + for i, option := range m.options { + isCursor := i == m.selectedIndex + + prefix := " " + if isCursor { + prefix = "> " + } + + labelStyle := component.T(prefix + option.label) + if isCursor { + labelStyle = labelStyle.Bold(true) + } + + optionComponents = append(optionComponents, component.VStackC( + labelStyle, + component.T(" "+option.description).Muted(), + component.SpacerV(1), + )) + } + + // Add note for selected wallet (cannot deselect) + footer := component.Empty() + if m.walletID == m.selectedWalletID { + footer = component.VStackC( + component.SpacerV(1), + component.T("Note: You cannot deselect the active wallet. Select another wallet first.").Muted(), + ) + } + + return component.VStackC( + header, + component.VStackC(optionComponents...), + footer, + ).Render() +} diff --git a/app/evm/wallet/actions/page_test.go b/app/evm/wallet/actions/page_test.go new file mode 100644 index 0000000..ea1a11b --- /dev/null +++ b/app/evm/wallet/actions/page_test.go @@ -0,0 +1,953 @@ +package actions + +import ( + "fmt" + "io" + "math/big" + "os" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + "github.com/rxtech-lab/smart-contract-cli/internal/config" + models "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/models/evm" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/sql" + walletsvc "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/wallet" + "github.com/rxtech-lab/smart-contract-cli/internal/storage" + "github.com/rxtech-lab/smart-contract-cli/internal/view" + "github.com/stretchr/testify/suite" + "go.uber.org/mock/gomock" +) + +// ActionsPageTestSuite tests the wallet actions page using teatest. +type ActionsPageTestSuite struct { + suite.Suite + testStoragePath string + sharedMemory storage.SharedMemory + router view.Router + mockCtrl *gomock.Controller +} + +func TestActionsPageTestSuite(t *testing.T) { + suite.Run(t, new(ActionsPageTestSuite)) +} + +func (s *ActionsPageTestSuite) SetupTest() { + // Create a temporary directory for test storage + tmpDir, err := os.MkdirTemp("", "smart-contract-cli-actions-test-*") + s.NoError(err, "Should create temp directory") + s.testStoragePath = tmpDir + + // Override the storage path for tests + err = os.Setenv("HOME", tmpDir) + s.NoError(err, "Should set HOME environment variable") + + // Create shared memory and router for each test + s.sharedMemory = storage.NewSharedMemory() + s.router = view.NewRouter() + + // Create mock controller + s.mockCtrl = gomock.NewController(s.T()) +} + +func (s *ActionsPageTestSuite) TearDownTest() { + // Clean up test storage + if s.testStoragePath != "" { + err := os.RemoveAll(s.testStoragePath) + s.NoError(err, "Should clean up test storage directory") + } + + // Finish mock controller + if s.mockCtrl != nil { + s.mockCtrl.Finish() + } +} + +func (s *ActionsPageTestSuite) getOutput(tm *teatest.TestModel) string { + output, err := io.ReadAll(tm.Output()) + s.NoError(err, "Should be able to read output") + return string(output) +} + +// setupMockWalletService creates a mock wallet service for testing. +func (s *ActionsPageTestSuite) setupMockWalletService() *walletsvc.MockWalletService { + return walletsvc.NewMockWalletService(s.mockCtrl) +} + +// setupMockStorage creates a mock storage for testing. +func (s *ActionsPageTestSuite) setupMockStorage() *sql.MockStorage { + return sql.NewMockStorage(s.mockCtrl) +} + +// createTestWallet creates a test wallet with balance. +func (s *ActionsPageTestSuite) createTestWallet(alias string, balanceEth string) *walletsvc.WalletWithBalance { + balance := new(big.Int) + if balanceEth != "" { + balance.SetString(balanceEth, 10) + } + + return &walletsvc.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: alias, + Address: "0x1111111111111111111111111111111111111111", + }, + Balance: balance, + } +} + +// TestNoConfigFound_ShowError tests error when GetCurrentConfig returns error. +func (s *ActionsPageTestSuite) TestNoConfigFound_ShowError() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Mock GetCurrentConfig to return error + mockStorage.EXPECT(). + GetCurrentConfig(). + Return(models.EVMConfig{}, fmt.Errorf("no current config found")) + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add wallet ID to router query params + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", map[string]string{"id": "1"}) + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + output := s.getOutput(testModel) + s.Contains(output, "Error:", "Should show error message") + s.Contains(output, "failed to get current config", "Should show config error") +} + +// TestNoEndpointConfigured_ShowError tests error when config has nil endpoint. +func (s *ActionsPageTestSuite) TestNoEndpointConfigured_ShowError() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Mock GetCurrentConfig to return config with nil endpoint + configNoEndpoint := models.EVMConfig{ + ID: 1, + Endpoint: nil, + SelectedWalletID: uintPtr(1), + } + mockStorage.EXPECT(). + GetCurrentConfig(). + Return(configNoEndpoint, nil) + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add wallet ID to router query params + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", map[string]string{"id": "1"}) + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + output := s.getOutput(testModel) + s.Contains(output, "Error:", "Should show error message") +} + +// TestNoSelectedWalletInConfig_ShowError tests error when config has nil SelectedWalletID. +func (s *ActionsPageTestSuite) TestNoSelectedWalletInConfig_ShowError() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Mock GetCurrentConfig to return config with nil SelectedWalletID + configNoSelected := models.EVMConfig{ + ID: 1, + Endpoint: &models.EVMEndpoint{ + Url: "http://localhost:8545", + }, + SelectedWalletID: nil, + } + mockStorage.EXPECT(). + GetCurrentConfig(). + Return(configNoSelected, nil) + + // Mock wallet service - it will be called before the SelectedWalletID check + testWallet := s.createTestWallet("Test Wallet", "1000000000000000000") + mockWalletSvc.EXPECT(). + GetWalletWithBalance(uint(1), "http://localhost:8545"). + Return(testWallet, nil) + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add wallet ID to router query params + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", map[string]string{"id": "1"}) + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + output := s.getOutput(testModel) + s.Contains(output, "Error:", "Should show error message") + s.Contains(output, "no selected wallet ID found in config", "Should show selected wallet error") +} + +// TestSelectActiveWallet tests displaying "Select as active wallet" option when wallet is not selected. +func (s *ActionsPageTestSuite) TestSelectActiveWallet() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Mock GetCurrentConfig - wallet 1 is being viewed, but wallet 2 is selected + validConfig := models.EVMConfig{ + ID: 1, + Endpoint: &models.EVMEndpoint{ + Url: "http://localhost:8545", + }, + SelectedWalletID: uintPtr(2), // Different wallet is selected + } + mockStorage.EXPECT(). + GetCurrentConfig(). + Return(validConfig, nil) + + // Mock wallet service to return wallet data + testWallet := s.createTestWallet("Test Wallet", "1000000000000000000") + mockWalletSvc.EXPECT(). + GetWalletWithBalance(uint(1), "http://localhost:8545"). + Return(testWallet, nil) + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add wallet ID to router query params + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/select", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return &mockComponent{} + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", map[string]string{"id": "1"}) + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + output := s.getOutput(testModel) + s.Contains(output, "Test Wallet", "Should show wallet name") + s.Contains(output, "Select as active wallet", "Should show select option") + s.NotContains(output, "★", "Should not show star indicator for non-selected wallet") + + // Press enter to select the wallet + testModel.Send(tea.KeyMsg{Type: tea.KeyEnter}) + time.Sleep(100 * time.Millisecond) + + // Verify navigation occurred + currentRoute := s.router.GetCurrentRoute() + s.Equal("/evm/wallet/select", currentRoute.Path, "Should navigate to select page") + s.Equal("1", s.router.GetQueryParam("id"), "Should pass wallet ID") +} + +// TestWalletIsSelected_NoSelectOption tests that "Select as active wallet" is hidden when wallet is selected. +func (s *ActionsPageTestSuite) TestWalletIsSelected_NoSelectOption() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Mock GetCurrentConfig - wallet 1 is both being viewed AND selected + validConfig := models.EVMConfig{ + ID: 1, + Endpoint: &models.EVMEndpoint{ + Url: "http://localhost:8545", + }, + SelectedWalletID: uintPtr(1), // Same wallet is selected + } + mockStorage.EXPECT(). + GetCurrentConfig(). + Return(validConfig, nil) + + // Mock wallet service to return wallet data + testWallet := s.createTestWallet("Selected Wallet", "2000000000000000000") + mockWalletSvc.EXPECT(). + GetWalletWithBalance(uint(1), "http://localhost:8545"). + Return(testWallet, nil) + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add wallet ID to router query params + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", map[string]string{"id": "1"}) + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + output := s.getOutput(testModel) + s.Contains(output, "Selected Wallet", "Should show wallet name") + s.Contains(output, "★", "Should show star indicator for selected wallet") + s.NotContains(output, "Select as active wallet", "Should NOT show select option") + s.Contains(output, "This is your currently selected wallet", "Should show selected wallet note") + s.Contains(output, "You cannot deselect the active wallet", "Should show deselect warning") + + // First option should be "View details" (not "Select as active wallet") + s.Contains(output, "> View details", "First option should be View details") +} + +// TestNavigationUpDown tests keyboard navigation through action options. +func (s *ActionsPageTestSuite) TestNavigationUpDown() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Mock GetCurrentConfig + validConfig := models.EVMConfig{ + ID: 1, + Endpoint: &models.EVMEndpoint{ + Url: "http://localhost:8545", + }, + SelectedWalletID: uintPtr(2), + } + mockStorage.EXPECT(). + GetCurrentConfig(). + Return(validConfig, nil) + + // Mock wallet service + testWallet := s.createTestWallet("Nav Test", "1000000000000000000") + mockWalletSvc.EXPECT(). + GetWalletWithBalance(uint(1), "http://localhost:8545"). + Return(testWallet, nil) + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add wallet ID to router query params + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", map[string]string{"id": "1"}) + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + // Initially cursor should be on first option + output := s.getOutput(testModel) + s.Contains(output, "> Select as active wallet", "Cursor should be on first option") + + // Press down arrow to move to second option + testModel.Send(tea.KeyMsg{Type: tea.KeyDown}) + time.Sleep(100 * time.Millisecond) + + output = s.getOutput(testModel) + s.Contains(output, "> View details", "Cursor should move to View details") + + // Press down again to move to third option + testModel.Send(tea.KeyMsg{Type: tea.KeyDown}) + time.Sleep(100 * time.Millisecond) + + output = s.getOutput(testModel) + s.Contains(output, "> Update wallet", "Cursor should move to Update wallet") + + // Press up to go back to second option + testModel.Send(tea.KeyMsg{Type: tea.KeyUp}) + time.Sleep(100 * time.Millisecond) + + output = s.getOutput(testModel) + s.Contains(output, "> View details", "Cursor should move back to View details") +} + +// TestNavigationWithVimKeys tests navigation using vim-style keys (j/k). +func (s *ActionsPageTestSuite) TestNavigationWithVimKeys() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Mock GetCurrentConfig + validConfig := models.EVMConfig{ + ID: 1, + Endpoint: &models.EVMEndpoint{ + Url: "http://localhost:8545", + }, + SelectedWalletID: uintPtr(2), + } + mockStorage.EXPECT(). + GetCurrentConfig(). + Return(validConfig, nil) + + // Mock wallet service + testWallet := s.createTestWallet("Vim Test", "1000000000000000000") + mockWalletSvc.EXPECT(). + GetWalletWithBalance(uint(1), "http://localhost:8545"). + Return(testWallet, nil) + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add wallet ID to router query params + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", map[string]string{"id": "1"}) + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + // Press 'j' to move down + testModel.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{'j'}, + }) + time.Sleep(100 * time.Millisecond) + + output := s.getOutput(testModel) + s.Contains(output, "> View details", "Should move down with 'j' key") + + // Press 'k' to move up + testModel.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{'k'}, + }) + time.Sleep(100 * time.Millisecond) + + output = s.getOutput(testModel) + s.Contains(output, "> Select as active wallet", "Should move up with 'k' key") +} + +// TestViewDetailsNavigation tests navigating to view details page. +func (s *ActionsPageTestSuite) TestViewDetailsNavigation() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Mock GetCurrentConfig + validConfig := models.EVMConfig{ + ID: 1, + Endpoint: &models.EVMEndpoint{ + Url: "http://localhost:8545", + }, + SelectedWalletID: uintPtr(1), + } + mockStorage.EXPECT(). + GetCurrentConfig(). + Return(validConfig, nil) + + // Mock wallet service + testWallet := s.createTestWallet("Details Test", "1000000000000000000") + mockWalletSvc.EXPECT(). + GetWalletWithBalance(uint(1), "http://localhost:8545"). + Return(testWallet, nil) + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add routes + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/details", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return &mockComponent{} + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", map[string]string{"id": "1"}) + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + // First option should be "View details" (wallet is selected, so no "Select" option) + output := s.getOutput(testModel) + s.Contains(output, "> View details", "First option should be View details") + + // Press enter to navigate + testModel.Send(tea.KeyMsg{Type: tea.KeyEnter}) + time.Sleep(100 * time.Millisecond) + + // Verify navigation + currentRoute := s.router.GetCurrentRoute() + s.Equal("/evm/wallet/details", currentRoute.Path, "Should navigate to details page") + s.Equal("1", s.router.GetQueryParam("id"), "Should pass wallet ID") +} + +// TestUpdateWalletNavigation tests navigating to update wallet page. +func (s *ActionsPageTestSuite) TestUpdateWalletNavigation() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Mock GetCurrentConfig + validConfig := models.EVMConfig{ + ID: 1, + Endpoint: &models.EVMEndpoint{ + Url: "http://localhost:8545", + }, + SelectedWalletID: uintPtr(1), + } + mockStorage.EXPECT(). + GetCurrentConfig(). + Return(validConfig, nil) + + // Mock wallet service + testWallet := s.createTestWallet("Update Test", "1000000000000000000") + mockWalletSvc.EXPECT(). + GetWalletWithBalance(uint(1), "http://localhost:8545"). + Return(testWallet, nil) + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add routes + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/update", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return &mockComponent{} + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", map[string]string{"id": "1"}) + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + // Navigate to "Update wallet" option (second option when wallet is selected) + testModel.Send(tea.KeyMsg{Type: tea.KeyDown}) + time.Sleep(100 * time.Millisecond) + + output := s.getOutput(testModel) + s.Contains(output, "> Update wallet", "Should be on Update wallet option") + + // Press enter to navigate + testModel.Send(tea.KeyMsg{Type: tea.KeyEnter}) + time.Sleep(100 * time.Millisecond) + + // Verify navigation + currentRoute := s.router.GetCurrentRoute() + s.Equal("/evm/wallet/update", currentRoute.Path, "Should navigate to update page") + s.Equal("1", s.router.GetQueryParam("id"), "Should pass wallet ID") +} + +// TestDeleteWalletNavigation tests navigating to delete wallet page. +func (s *ActionsPageTestSuite) TestDeleteWalletNavigation() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Mock GetCurrentConfig + validConfig := models.EVMConfig{ + ID: 1, + Endpoint: &models.EVMEndpoint{ + Url: "http://localhost:8545", + }, + SelectedWalletID: uintPtr(1), + } + mockStorage.EXPECT(). + GetCurrentConfig(). + Return(validConfig, nil) + + // Mock wallet service + testWallet := s.createTestWallet("Delete Test", "1000000000000000000") + mockWalletSvc.EXPECT(). + GetWalletWithBalance(uint(1), "http://localhost:8545"). + Return(testWallet, nil) + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add routes + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/delete", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return &mockComponent{} + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", map[string]string{"id": "1"}) + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + // Navigate to "Delete wallet" option (third option when wallet is selected) + testModel.Send(tea.KeyMsg{Type: tea.KeyDown}) + testModel.Send(tea.KeyMsg{Type: tea.KeyDown}) + time.Sleep(100 * time.Millisecond) + + output := s.getOutput(testModel) + s.Contains(output, "> Delete wallet", "Should be on Delete wallet option") + + // Press enter to navigate + testModel.Send(tea.KeyMsg{Type: tea.KeyEnter}) + time.Sleep(100 * time.Millisecond) + + // Verify navigation + currentRoute := s.router.GetCurrentRoute() + s.Equal("/evm/wallet/delete", currentRoute.Path, "Should navigate to delete page") + s.Equal("1", s.router.GetQueryParam("id"), "Should pass wallet ID") +} + +// TestWalletLoadError tests displaying error when wallet service fails. +func (s *ActionsPageTestSuite) TestWalletLoadError() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Mock GetCurrentConfig + validConfig := models.EVMConfig{ + ID: 1, + Endpoint: &models.EVMEndpoint{ + Url: "http://localhost:8545", + }, + SelectedWalletID: uintPtr(1), + } + mockStorage.EXPECT(). + GetCurrentConfig(). + Return(validConfig, nil) + + // Mock wallet service to return error + mockWalletSvc.EXPECT(). + GetWalletWithBalance(uint(1), "http://localhost:8545"). + Return(nil, fmt.Errorf("wallet not found")) + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add wallet ID to router query params + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", map[string]string{"id": "1"}) + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + output := s.getOutput(testModel) + s.Contains(output, "Error:", "Should show error message") + s.Contains(output, "failed to load wallet", "Should show wallet load error") + s.NotContains(output, "View details", "Should not show action options") +} + +// TestBalanceDisplay tests balance formatting and display. +func (s *ActionsPageTestSuite) TestBalanceDisplay() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Mock GetCurrentConfig + validConfig := models.EVMConfig{ + ID: 1, + Endpoint: &models.EVMEndpoint{ + Url: "http://localhost:8545", + }, + SelectedWalletID: uintPtr(1), + } + mockStorage.EXPECT(). + GetCurrentConfig(). + Return(validConfig, nil) + + // Mock wallet service with specific balance (1.5 ETH) + testWallet := s.createTestWallet("Balance Test", "1500000000000000000") + mockWalletSvc.EXPECT(). + GetWalletWithBalance(uint(1), "http://localhost:8545"). + Return(testWallet, nil) + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add wallet ID to router query params + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", map[string]string{"id": "1"}) + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + output := s.getOutput(testModel) + s.Contains(output, "Balance: 1.5000 ETH", "Should show formatted balance") + s.Contains(output, "Address: 0x1111111111111111111111111111111111111111", "Should show address") +} + +// TestBalanceUnavailable tests display when balance is nil. +func (s *ActionsPageTestSuite) TestBalanceUnavailable() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Mock GetCurrentConfig + validConfig := models.EVMConfig{ + ID: 1, + Endpoint: &models.EVMEndpoint{ + Url: "http://localhost:8545", + }, + SelectedWalletID: uintPtr(1), + } + mockStorage.EXPECT(). + GetCurrentConfig(). + Return(validConfig, nil) + + // Mock wallet service with nil balance + testWallet := &walletsvc.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "Offline Wallet", + Address: "0x1111111111111111111111111111111111111111", + }, + Balance: nil, // Nil balance + } + mockWalletSvc.EXPECT(). + GetWalletWithBalance(uint(1), "http://localhost:8545"). + Return(testWallet, nil) + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add wallet ID to router query params + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", map[string]string{"id": "1"}) + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + output := s.getOutput(testModel) + s.Contains(output, "Offline Wallet", "Should show wallet name") + s.Contains(output, "unavailable ⚠", "Should show unavailable for nil balance") +} + +// TestMissingWalletID tests error when wallet ID is not in query params. +func (s *ActionsPageTestSuite) TestMissingWalletID() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add route without wallet ID + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", nil) // No query params + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + output := s.getOutput(testModel) + s.Contains(output, "Error:", "Should show error message") + s.Contains(output, "wallet ID not provided", "Should show missing ID error") +} + +// TestInvalidWalletID tests error when wallet ID is not a valid number. +func (s *ActionsPageTestSuite) TestInvalidWalletID() { + mockStorage := s.setupMockStorage() + mockWalletSvc := s.setupMockWalletService() + + // Set up shared memory + err := s.sharedMemory.Set(config.StorageClientKey, mockStorage) + s.NoError(err, "Should set storage client in shared memory") + + // Add route with invalid wallet ID + s.router.AddRoute(view.Route{ + Path: "/evm/wallet/actions", + Component: func(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + }, + }) + err = s.router.NavigateTo("/evm/wallet/actions", map[string]string{"id": "invalid"}) + s.NoError(err) + + model := NewPageWithService(s.router, s.sharedMemory, mockWalletSvc) + testModel := teatest.NewTestModel( + s.T(), + model, + teatest.WithInitialTermSize(300, 100), + ) + + // Wait for wallet loading + time.Sleep(300 * time.Millisecond) + + output := s.getOutput(testModel) + s.Contains(output, "Error:", "Should show error message") + s.Contains(output, "invalid wallet ID", "Should show invalid ID error") +} + +// mockComponent is a simple mock component for router testing. +type mockComponent struct{} + +func (m *mockComponent) Init() tea.Cmd { return nil } +func (m *mockComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m *mockComponent) View() string { return "mock" } +func (m *mockComponent) Help() (string, view.HelpDisplayOption) { + return "", view.HelpDisplayOptionAppend +} + +// uintPtr is a helper function to create a pointer to a uint. +func uintPtr(u uint) *uint { + return &u +} diff --git a/app/evm/wallet/add/page.go b/app/evm/wallet/add/page.go new file mode 100644 index 0000000..fc565f4 --- /dev/null +++ b/app/evm/wallet/add/page.go @@ -0,0 +1,1037 @@ +package add + +import ( + "fmt" + "math/big" + "strings" + + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/wallet" + "github.com/rxtech-lab/smart-contract-cli/internal/log" + "github.com/rxtech-lab/smart-contract-cli/internal/storage" + "github.com/rxtech-lab/smart-contract-cli/internal/ui/component" + "github.com/rxtech-lab/smart-contract-cli/internal/utils" + "github.com/rxtech-lab/smart-contract-cli/internal/view" +) + +var logger, _ = log.NewFileLogger("./logs/evm/wallet/add.log") + +type addStep int + +const ( + stepSelectMethod addStep = iota + stepEnterAlias + stepEnterPrivateKey + stepEnterMnemonic + stepSelectDerivationPath + stepGenerating + stepShowBackup + stepConfirm + stepSuccess + stepError +) + +type importMethod int + +const ( + methodPrivateKey importMethod = iota + methodMnemonic + methodGenerate +) + +type methodOption struct { + label string + description string + method importMethod +} + +type derivationPathOption struct { + label string + description string + path string +} + +type confirmOption struct { + label string + value bool +} + +type Model struct { + router view.Router + sharedMemory storage.SharedMemory + walletService wallet.WalletService + + currentStep addStep + selectedIndex int + method importMethod + + // Method selection options + methodOptions []methodOption + + // Derivation path options + derivationOptions []derivationPathOption + customPathInput textinput.Model + + // Form inputs + aliasInput textinput.Model + pkeyInput textinput.Model + mnemonicInput textarea.Model + + // Generated wallet data + generatedMnemonic string + generatedPKey string + generatedAddress string + + // Confirmation data + confirmedWallet *wallet.WalletWithBalance + confirmOptions []confirmOption + rpcEndpoint string + + errorMsg string +} + +func NewPage(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(router, sharedMemory, nil) +} + +// NewPageWithService creates a new add wallet page with an optional wallet service (for testing). +func NewPageWithService(router view.Router, sharedMemory storage.SharedMemory, walletService wallet.WalletService) view.View { + aliasInput := textinput.New() + aliasInput.Placeholder = "Enter wallet alias" + aliasInput.Width = 40 + + pkeyInput := textinput.New() + pkeyInput.Placeholder = "Enter private key (hex format)" + pkeyInput.EchoMode = textinput.EchoPassword + pkeyInput.EchoCharacter = '*' + pkeyInput.Width = 66 + + mnemonicInput := textarea.New() + mnemonicInput.Placeholder = "Enter your 12 or 24 word mnemonic phrase (space-separated)" + mnemonicInput.SetWidth(76) + mnemonicInput.SetHeight(5) + + customPathInput := textinput.New() + customPathInput.Placeholder = "m/44'/60'/0'/0/0" + customPathInput.Width = 40 + + return Model{ + router: router, + sharedMemory: sharedMemory, + walletService: walletService, + currentStep: stepSelectMethod, + selectedIndex: 0, + aliasInput: aliasInput, + pkeyInput: pkeyInput, + mnemonicInput: mnemonicInput, + customPathInput: customPathInput, + methodOptions: []methodOption{ + {label: "Import from private key", description: "Import wallet using a private key (hex format)", method: methodPrivateKey}, + {label: "Import from mnemonic phrase", description: "Import wallet using a 12 or 24 word mnemonic phrase", method: methodMnemonic}, + {label: "Generate new wallet", description: "Create a new random wallet with private key", method: methodGenerate}, + }, + derivationOptions: []derivationPathOption{ + {label: "m/44'/60'/0'/0/0", description: "Ethereum standard (default)", path: "m/44'/60'/0'/0/0"}, + {label: "m/44'/60'/0'/0/1", description: "Ethereum standard (account 1)", path: "m/44'/60'/0'/0/1"}, + {label: "m/44'/60'/0'/0/2", description: "Ethereum standard (account 2)", path: "m/44'/60'/0'/0/2"}, + {label: "Custom path", description: "Enter your own derivation path", path: "custom"}, + }, + confirmOptions: []confirmOption{ + {label: "Save wallet", value: true}, + {label: "Cancel", value: false}, + }, + } +} + +func (m Model) Init() tea.Cmd { + return m.loadWalletService +} + +func (m Model) createWalletService() (wallet.WalletService, error) { + sqlStorage, err := utils.GetStorageClientFromSharedMemory(m.sharedMemory) + if err != nil { + logger.Error("Failed to get storage client from shared memory: %v", err) + return nil, fmt.Errorf("failed to get storage client from shared memory: %w", err) + } + + secureStorage, _, err := utils.GetSecureStorageFromSharedMemory(m.sharedMemory) + if err != nil { + logger.Error("Failed to get secure storage from shared memory: %v", err) + return nil, fmt.Errorf("failed to get secure storage from shared memory: %w", err) + } + + return wallet.NewWalletService(sqlStorage, secureStorage), nil +} + +func (m Model) loadWalletService() tea.Msg { + walletService := m.walletService + if walletService == nil { + svc, err := m.createWalletService() + if err != nil { + return serviceLoadedMsg{err: err} + } + walletService = svc + } + + return serviceLoadedMsg{walletService: walletService} +} + +type serviceLoadedMsg struct { + walletService wallet.WalletService + err error +} + +type walletImportedMsg struct { + wallet *wallet.WalletWithBalance + rpcEndpoint string + err error +} + +type walletGeneratedMsg struct { + wallet *wallet.WalletWithBalance + mnemonic string + privateKey string + rpcEndpoint string + err error +} + +func (m Model) importPrivateKey() tea.Msg { + alias := m.aliasInput.Value() + privateKey := m.pkeyInput.Value() + + if alias == "" { + return walletImportedMsg{err: fmt.Errorf("alias cannot be empty")} + } + + if privateKey == "" { + return walletImportedMsg{err: fmt.Errorf("private key cannot be empty")} + } + + // Validate private key + err := m.walletService.ValidatePrivateKey(privateKey) + if err != nil { + return walletImportedMsg{err: fmt.Errorf("invalid private key format")} + } + + // Check for duplicate alias + exists, err := m.walletService.WalletExistsByAlias(alias) + if err != nil { + return walletImportedMsg{err: fmt.Errorf("failed to check for duplicate alias: %w", err)} + } + if exists { + return walletImportedMsg{err: fmt.Errorf("wallet with alias '%s' already exists", alias)} + } + + // Import wallet + walletData, err := m.walletService.ImportPrivateKey(alias, privateKey) + if err != nil { + return walletImportedMsg{err: err} + } + + // Check for duplicate address + exists, err = m.walletService.WalletExistsByAddress(walletData.Address) + if err == nil && exists { + // Delete the newly created wallet + _ = m.walletService.DeleteWallet(walletData.ID) + return walletImportedMsg{err: fmt.Errorf("wallet already exists with address: %s", walletData.Address)} + } + + // Get RPC endpoint from database + sqlStorage, err := utils.GetStorageClientFromSharedMemory(m.sharedMemory) + if err != nil { + logger.Error("Failed to get storage client from shared memory: %v", err) + return walletImportedMsg{err: fmt.Errorf("failed to get storage client from shared memory: %w", err)} + } + + // Get the current config + config, err := sqlStorage.GetCurrentConfig() + if err != nil { + logger.Error("Failed to get current config: %v", err) + return walletImportedMsg{err: fmt.Errorf("failed to get current config: %w", err)} + } + if config.Endpoint == nil { + logger.Error("No RPC endpoint configured") + return walletImportedMsg{err: fmt.Errorf("no RPC endpoint configured. Please configure an endpoint first")} + } + rpcEndpoint := config.Endpoint.Url + + // Load with balance + walletWithBalance, err := m.walletService.GetWalletWithBalance(walletData.ID, rpcEndpoint) + if err != nil { + logger.Warn("Failed to load balance: %v", err) + // Continue anyway, balance is optional + walletWithBalance = &wallet.WalletWithBalance{ + Wallet: *walletData, + } + } + + return walletImportedMsg{wallet: walletWithBalance, rpcEndpoint: rpcEndpoint} +} + +func (m Model) importMnemonic() tea.Msg { + alias := m.aliasInput.Value() + mnemonic := strings.TrimSpace(m.mnemonicInput.Value()) + + if alias == "" { + return walletImportedMsg{err: fmt.Errorf("alias cannot be empty")} + } + + if mnemonic == "" { + return walletImportedMsg{err: fmt.Errorf("mnemonic cannot be empty")} + } + + // Validate mnemonic + err := m.walletService.ValidateMnemonic(mnemonic) + if err != nil { + wordCount := len(strings.Fields(mnemonic)) + return walletImportedMsg{err: fmt.Errorf("invalid mnemonic phrase (got %d words, expected 12 or 24)", wordCount)} + } + + // Check for duplicate alias + exists, err := m.walletService.WalletExistsByAlias(alias) + if err != nil { + return walletImportedMsg{err: fmt.Errorf("failed to check for duplicate alias: %w", err)} + } + if exists { + return walletImportedMsg{err: fmt.Errorf("wallet with alias '%s' already exists", alias)} + } + + // Get selected derivation path + var derivationPath string + if m.selectedIndex < len(m.derivationOptions)-1 { + derivationPath = m.derivationOptions[m.selectedIndex].path + } else { + // Custom path + derivationPath = m.customPathInput.Value() + if derivationPath == "" { + derivationPath = "m/44'/60'/0'/0/0" // Default + } + } + + // Import wallet + walletData, err := m.walletService.ImportMnemonic(alias, mnemonic, derivationPath) + if err != nil { + return walletImportedMsg{err: err} + } + + // Check for duplicate address + exists, err = m.walletService.WalletExistsByAddress(walletData.Address) + if err == nil && exists { + // Delete the newly created wallet + _ = m.walletService.DeleteWallet(walletData.ID) + return walletImportedMsg{err: fmt.Errorf("wallet already exists with address: %s", walletData.Address)} + } + + // Get RPC endpoint from database + sqlStorage, err := utils.GetStorageClientFromSharedMemory(m.sharedMemory) + if err != nil { + logger.Error("Failed to get storage client from shared memory: %v", err) + return walletImportedMsg{err: fmt.Errorf("failed to get storage client from shared memory: %w", err)} + } + + // Get the current config + config, err := sqlStorage.GetCurrentConfig() + if err != nil { + logger.Error("Failed to get current config: %v", err) + return walletImportedMsg{err: fmt.Errorf("failed to get current config: %w", err)} + } + if config.Endpoint == nil { + logger.Error("No RPC endpoint configured") + return walletImportedMsg{err: fmt.Errorf("no RPC endpoint configured. Please configure an endpoint first")} + } + rpcEndpoint := config.Endpoint.Url + + // Load with balance + walletWithBalance, err := m.walletService.GetWalletWithBalance(walletData.ID, rpcEndpoint) + if err != nil { + logger.Warn("Failed to load balance: %v", err) + walletWithBalance = &wallet.WalletWithBalance{ + Wallet: *walletData, + } + } + + return walletImportedMsg{wallet: walletWithBalance, rpcEndpoint: rpcEndpoint} +} + +func (m Model) generateWallet() tea.Msg { + alias := m.aliasInput.Value() + + if alias == "" { + return walletGeneratedMsg{err: fmt.Errorf("alias cannot be empty")} + } + + // Check for duplicate alias + exists, err := m.walletService.WalletExistsByAlias(alias) + if err != nil { + return walletGeneratedMsg{err: fmt.Errorf("failed to check for duplicate alias: %w", err)} + } + if exists { + return walletGeneratedMsg{err: fmt.Errorf("wallet with alias '%s' already exists", alias)} + } + + // Generate wallet + walletData, mnemonic, privateKey, err := m.walletService.GenerateWallet(alias) + if err != nil { + return walletGeneratedMsg{err: err} + } + + // Get RPC endpoint from database + sqlStorage, err := utils.GetStorageClientFromSharedMemory(m.sharedMemory) + if err != nil { + logger.Error("Failed to get storage client from shared memory: %v", err) + return walletGeneratedMsg{err: fmt.Errorf("failed to get storage client from shared memory: %w", err)} + } + + // Get the current config + config, err := sqlStorage.GetCurrentConfig() + if err != nil { + logger.Error("Failed to get current config: %v", err) + return walletGeneratedMsg{err: fmt.Errorf("failed to get current config: %w", err)} + } + if config.Endpoint == nil { + logger.Error("No RPC endpoint configured") + return walletGeneratedMsg{err: fmt.Errorf("no RPC endpoint configured. Please configure an endpoint first")} + } + rpcEndpoint := config.Endpoint.Url + + // Load with balance + walletWithBalance, err := m.walletService.GetWalletWithBalance(walletData.ID, rpcEndpoint) + if err != nil { + logger.Warn("Failed to load balance: %v", err) + walletWithBalance = &wallet.WalletWithBalance{ + Wallet: *walletData, + } + } + + return walletGeneratedMsg{ + wallet: walletWithBalance, + mnemonic: mnemonic, + privateKey: privateKey, + rpcEndpoint: rpcEndpoint, + } +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case serviceLoadedMsg: + if msg.err != nil { + m.currentStep = stepError + m.errorMsg = msg.err.Error() + return m, nil + } + m.walletService = msg.walletService + return m, nil + + case walletImportedMsg: + if msg.err != nil { + m.currentStep = stepError + m.errorMsg = msg.err.Error() + return m, nil + } + + m.confirmedWallet = msg.wallet + m.rpcEndpoint = msg.rpcEndpoint + m.currentStep = stepConfirm + m.selectedIndex = 0 + return m, nil + + case walletGeneratedMsg: + if msg.err != nil { + m.currentStep = stepError + m.errorMsg = msg.err.Error() + return m, nil + } + + m.confirmedWallet = msg.wallet + m.generatedMnemonic = msg.mnemonic + m.generatedPKey = msg.privateKey + m.generatedAddress = msg.wallet.Wallet.Address + m.rpcEndpoint = msg.rpcEndpoint + m.currentStep = stepShowBackup + m.selectedIndex = 0 + return m, nil + + case tea.KeyMsg: + switch m.currentStep { + case stepSelectMethod: + return m.handleSelectMethod(msg) + + case stepEnterAlias: + return m.handleEnterAlias(msg) + + case stepEnterPrivateKey: + return m.handleEnterPrivateKey(msg) + + case stepEnterMnemonic: + return m.handleEnterMnemonic(msg) + + case stepSelectDerivationPath: + return m.handleSelectDerivationPath(msg) + + case stepShowBackup: + return m.handleShowBackup(msg) + + case stepConfirm: + return m.handleConfirm(msg) + + case stepSuccess: + // Any key returns to wallet list + return m, func() tea.Msg { + _ = m.router.NavigateTo("/evm/wallet", nil) + return nil + } + + case stepError: + // Any key returns to wallet list + return m, func() tea.Msg { + _ = m.router.NavigateTo("/evm/wallet", nil) + return nil + } + } + } + + return m, cmd +} + +func (m Model) handleSelectMethod(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.selectedIndex > 0 { + m.selectedIndex-- + } + + case "down", "j": + if m.selectedIndex < len(m.methodOptions)-1 { + m.selectedIndex++ + } + + case "enter": + m.method = m.methodOptions[m.selectedIndex].method + m.currentStep = stepEnterAlias + m.selectedIndex = 0 + m.aliasInput.Focus() + return m, textinput.Blink + } + + return m, nil +} + +func (m Model) handleEnterAlias(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.aliasInput, cmd = m.aliasInput.Update(msg) + + switch msg.String() { + case "enter": + switch m.method { + case methodPrivateKey: + m.currentStep = stepEnterPrivateKey + m.pkeyInput.Focus() + return m, textinput.Blink + + case methodMnemonic: + m.currentStep = stepEnterMnemonic + m.mnemonicInput.Focus() + return m, textarea.Blink + + case methodGenerate: + m.currentStep = stepGenerating + return m, m.generateWallet + } + + case "esc": + m.currentStep = stepSelectMethod + m.selectedIndex = 0 + } + + return m, cmd +} + +func (m Model) handleEnterPrivateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.pkeyInput, cmd = m.pkeyInput.Update(msg) + + switch msg.String() { + case "enter": + return m, m.importPrivateKey + + case "esc": + m.currentStep = stepEnterAlias + } + + return m, cmd +} + +func (m Model) handleEnterMnemonic(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.mnemonicInput, cmd = m.mnemonicInput.Update(msg) + + switch msg.String() { + case "ctrl+s": // Use Ctrl+S to proceed (Enter adds newline in textarea) + m.currentStep = stepSelectDerivationPath + m.selectedIndex = 0 + + case "esc": + m.currentStep = stepEnterAlias + } + + return m, cmd +} + +func (m Model) handleSelectDerivationPath(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // If custom path is selected, handle text input + if m.selectedIndex == len(m.derivationOptions)-1 { + var cmd tea.Cmd + m.customPathInput, cmd = m.customPathInput.Update(msg) + + switch msg.String() { + case "enter": + return m, m.importMnemonic + + case "esc": + m.currentStep = stepEnterMnemonic + } + + return m, cmd + } + + // Handle list selection + switch msg.String() { + case "up", "k": + if m.selectedIndex > 0 { + m.selectedIndex-- + } + + case "down", "j": + if m.selectedIndex < len(m.derivationOptions)-1 { + m.selectedIndex++ + } else { + // Move to custom path input + m.selectedIndex++ + m.customPathInput.Focus() + return m, textinput.Blink + } + + case "enter": + return m, m.importMnemonic + + case "esc": + m.currentStep = stepEnterMnemonic + } + + return m, nil +} + +func (m Model) handleShowBackup(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.selectedIndex > 0 { + m.selectedIndex-- + } + + case "down", "j": + if m.selectedIndex < 1 { + m.selectedIndex++ + } + + case "enter": + if m.selectedIndex == 0 { + // User confirmed they saved credentials + m.currentStep = stepSuccess + } else { + // User cancelled, delete the wallet + _ = m.walletService.DeleteWallet(m.confirmedWallet.Wallet.ID) + return m, func() tea.Msg { + _ = m.router.NavigateTo("/evm/wallet", nil) + return nil + } + } + } + + return m, nil +} + +func (m Model) handleConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.selectedIndex > 0 { + m.selectedIndex-- + } + + case "down", "j": + if m.selectedIndex < len(m.confirmOptions)-1 { + m.selectedIndex++ + } + + case "enter": + option := m.confirmOptions[m.selectedIndex] + if option.value { + // User confirmed, wallet is already saved + m.currentStep = stepSuccess + } else { + // User cancelled, delete the wallet + _ = m.walletService.DeleteWallet(m.confirmedWallet.Wallet.ID) + return m, func() tea.Msg { + _ = m.router.NavigateTo("/evm/wallet", nil) + return nil + } + } + } + + return m, nil +} + +func (m Model) Help() (string, view.HelpDisplayOption) { + switch m.currentStep { + case stepEnterAlias, stepEnterPrivateKey: + return "enter: next • esc: cancel", view.HelpDisplayOptionOverride + case stepEnterMnemonic: + return "ctrl+s: next • esc: cancel", view.HelpDisplayOptionOverride + case stepSelectDerivationPath: + if m.selectedIndex == len(m.derivationOptions)-1 { + return "enter: import • esc: back", view.HelpDisplayOptionOverride + } + return "↑/k: up • ↓/j: down • enter: select • esc: back", view.HelpDisplayOptionOverride + case stepGenerating: + return "Generating wallet...", view.HelpDisplayOptionOverride + case stepSuccess, stepError: + return "Press any key to return to wallet list...", view.HelpDisplayOptionOverride + default: + return "↑/k: up • ↓/j: down • enter: select • esc: cancel", view.HelpDisplayOptionAppend + } +} + +func (m Model) View() string { + switch m.currentStep { + case stepSelectMethod: + return m.renderSelectMethod() + case stepEnterAlias: + return m.renderEnterAlias() + case stepEnterPrivateKey: + return m.renderEnterPrivateKey() + case stepEnterMnemonic: + return m.renderEnterMnemonic() + case stepSelectDerivationPath: + return m.renderSelectDerivationPath() + case stepGenerating: + return m.renderGenerating() + case stepShowBackup: + return m.renderShowBackup() + case stepConfirm: + return m.renderConfirm() + case stepSuccess: + return m.renderSuccess() + case stepError: + return m.renderError() + default: + return "" + } +} + +func (m Model) renderSelectMethod() string { + optionComponents := make([]component.Component, 0) + for i, option := range m.methodOptions { + isCursor := i == m.selectedIndex + + prefix := " " + if isCursor { + prefix = "> " + } + + labelStyle := component.T(prefix + option.label) + if isCursor { + labelStyle = labelStyle.Bold(true) + } + + optionComponents = append(optionComponents, component.VStackC( + labelStyle, + component.T(" "+option.description).Muted(), + component.SpacerV(1), + )) + } + + return component.VStackC( + component.T("Add New Wallet").Bold(true).Primary(), + component.SpacerV(1), + component.T("How would you like to import the wallet?").Bold(true), + component.SpacerV(1), + component.VStackC(optionComponents...), + ).Render() +} + +func (m Model) renderEnterAlias() string { + stepText := "Step 1/3" + if m.method == methodMnemonic { + stepText = "Step 1/4" + } + + var methodName string + switch m.method { + case methodMnemonic: + methodName = "Mnemonic Import" + case methodGenerate: + methodName = "Generate New" + default: + methodName = "Private Key Import" + } + + return component.VStackC( + component.T("Add New Wallet - "+methodName).Bold(true).Primary(), + component.SpacerV(1), + component.T(stepText+": Enter Wallet Alias").Bold(true), + component.SpacerV(1), + component.T("Give your wallet a memorable name:"), + component.SpacerV(1), + component.T("Alias: "+m.aliasInput.View()), + ).Render() +} + +func (m Model) renderEnterPrivateKey() string { + return component.VStackC( + component.T("Add New Wallet - Private Key Import").Bold(true).Primary(), + component.SpacerV(1), + component.T("Step 2/3: Enter Private Key").Bold(true), + component.SpacerV(1), + component.T("Enter your private key (hex format, with or without 0x prefix):"), + component.SpacerV(1), + component.T("Private Key: "+m.pkeyInput.View()), + component.SpacerV(1), + component.T("⚠ Warning: Never share your private key with anyone!").Warning(), + component.T("Keep it secure and private.").Warning(), + ).Render() +} + +func (m Model) renderEnterMnemonic() string { + mnemonic := m.mnemonicInput.Value() + wordCount := len(strings.Fields(strings.TrimSpace(mnemonic))) + + statusText := fmt.Sprintf("Words entered: %d/12", wordCount) + if wordCount >= 12 { + if wordCount == 12 || wordCount == 24 { + statusText = fmt.Sprintf("Words entered: %d/%d ✓", wordCount, wordCount) + } else { + statusText = fmt.Sprintf("Words entered: %d (expected 12 or 24)", wordCount) + } + } + + return component.VStackC( + component.T("Add New Wallet - Mnemonic Import").Bold(true).Primary(), + component.SpacerV(1), + component.T("Step 2/4: Enter Mnemonic Phrase").Bold(true), + component.SpacerV(1), + component.T("Enter your 12 or 24 word mnemonic phrase (space-separated):"), + component.SpacerV(1), + component.T("Mnemonic:"), + component.T("────────────────────────────────────────────────────────────────────────────").Muted(), + component.T(m.mnemonicInput.View()), + component.T("────────────────────────────────────────────────────────────────────────────").Muted(), + component.SpacerV(1), + component.T(statusText).Muted(), + component.SpacerV(1), + component.T("⚠ Warning: Never share your mnemonic phrase with anyone!").Warning(), + ).Render() +} + +func (m Model) renderSelectDerivationPath() string { + // If custom path is selected + if m.selectedIndex == len(m.derivationOptions)-1 { + return component.VStackC( + component.T("Add New Wallet - Mnemonic Import").Bold(true).Primary(), + component.SpacerV(1), + component.T("Step 3/4: Custom Derivation Path").Bold(true), + component.SpacerV(1), + component.T("Enter your custom derivation path:"), + component.SpacerV(1), + component.T("Path: "+m.customPathInput.View()), + component.SpacerV(1), + component.T("Common format: m/44'/60'/0'/0/0").Muted(), + ).Render() + } + + optionComponents := make([]component.Component, 0) + for i, option := range m.derivationOptions { + isCursor := i == m.selectedIndex + + prefix := " " + if isCursor { + prefix = "> " + } + + labelStyle := component.T(prefix + option.label) + if isCursor { + labelStyle = labelStyle.Bold(true) + } + + optionComponents = append(optionComponents, component.VStackC( + labelStyle, + component.T(" "+option.description).Muted(), + component.SpacerV(1), + )) + } + + return component.VStackC( + component.T("Add New Wallet - Mnemonic Import").Bold(true).Primary(), + component.SpacerV(1), + component.T("Step 3/4: Select Derivation Path").Bold(true), + component.SpacerV(1), + component.T("Choose the derivation path for your wallet:"), + component.SpacerV(1), + component.VStackC(optionComponents...), + component.SpacerV(1), + component.T("Tip: Most wallets (MetaMask, Ledger, Trezor) use m/44'/60'/0'/0/0").Muted(), + ).Render() +} + +func (m Model) renderGenerating() string { + return component.VStackC( + component.T("Add New Wallet - Generate New").Bold(true).Primary(), + component.SpacerV(1), + component.T("Step 2/3: Generating Wallet").Bold(true), + component.SpacerV(1), + component.T("Generating secure random wallet..."), + component.SpacerV(1), + component.T("⠋ Creating cryptographic keys...").Muted(), + ).Render() +} + +func (m Model) renderShowBackup() string { + optionComponents := []component.Component{ + component.T("> I have saved my credentials securely").Bold(true), + component.T(" Confirm and add wallet to account").Muted(), + component.SpacerV(1), + component.T(" Cancel").Muted(), + component.T(" Discard this wallet").Muted(), + } + + if m.selectedIndex == 1 { + optionComponents = []component.Component{ + component.T(" I have saved my credentials securely").Muted(), + component.T(" Confirm and add wallet to account").Muted(), + component.SpacerV(1), + component.T("> Cancel").Bold(true), + component.T(" Discard this wallet").Muted(), + } + } + + return component.VStackC( + component.T("Add New Wallet - Backup Your Wallet").Bold(true).Primary(), + component.SpacerV(1), + component.T("✓ Wallet successfully generated!").Success(), + component.SpacerV(1), + component.T("⚠ IMPORTANT: Save these credentials securely!").Warning(), + component.SpacerV(1), + component.T("Mnemonic Phrase (12 words):").Bold(true), + component.T("────────────────────────────────────────────────────────────────────────────").Muted(), + component.T(m.generatedMnemonic), + component.T("────────────────────────────────────────────────────────────────────────────").Muted(), + component.SpacerV(1), + component.T("Private Key:").Bold(true), + component.T("────────────────────────────────────────────────────────────────────────────").Muted(), + component.T(m.generatedPKey), + component.T("────────────────────────────────────────────────────────────────────────────").Muted(), + component.SpacerV(1), + component.T("Address: "+m.generatedAddress).Muted(), + component.SpacerV(1), + component.T("⚠ Write these down and store them in a secure location!").Warning(), + component.T("⚠ Anyone with access to these can control your funds!").Warning(), + component.T("⚠ Lost credentials cannot be recovered!").Warning(), + component.SpacerV(1), + component.VStackC(optionComponents...), + ).Render() +} + +func (m Model) renderConfirm() string { + if m.confirmedWallet == nil { + return "" + } + + balanceStr := "0.0000 ETH" + if m.confirmedWallet.Error == nil && m.confirmedWallet.Balance != nil { + ethValue := new(big.Float).Quo( + new(big.Float).SetInt(m.confirmedWallet.Balance), + new(big.Float).SetInt(big.NewInt(1e18)), + ) + balanceStr = fmt.Sprintf("%.4f ETH", ethValue) + } + + derivationInfo := component.Empty() + if m.method == methodMnemonic && m.confirmedWallet.Wallet.DerivationPath != nil { + derivationInfo = component.T("• Derivation Path: " + *m.confirmedWallet.Wallet.DerivationPath).Muted() + } + + optionComponents := make([]component.Component, 0) + for i, option := range m.confirmOptions { + isCursor := i == m.selectedIndex + + prefix := " " + if isCursor { + prefix = "> " + } + + labelStyle := component.T(prefix + option.label) + if isCursor { + labelStyle = labelStyle.Bold(true) + } + + var description string + if option.value { + description = "Add this wallet to your account" + } else { + description = "Discard and start over" + } + + optionComponents = append(optionComponents, component.VStackC( + labelStyle, + component.T(" "+description).Muted(), + component.SpacerV(1), + )) + } + + title := "successfully imported!" + if m.method == methodMnemonic { + title = "successfully imported from mnemonic!" + } + + return component.VStackC( + component.T("Add New Wallet - Confirmation").Bold(true).Primary(), + component.SpacerV(1), + component.T("✓ Wallet "+title).Success(), + component.SpacerV(1), + component.T("Wallet Details:").Bold(true), + component.T("• Alias: "+m.confirmedWallet.Wallet.Alias).Muted(), + derivationInfo, + component.T("• Address: "+m.confirmedWallet.Wallet.Address).Muted(), + component.T("• Balance: "+balanceStr+" (on "+m.rpcEndpoint+")").Muted(), + component.SpacerV(1), + component.T("Derived Information:").Bold(true), + component.T("• Private Key: Available (hidden for security)").Muted(), + component.T("• Checksum Address: ✓ Valid").Muted(), + component.SpacerV(1), + component.VStackC(optionComponents...), + ).Render() +} + +func (m Model) renderSuccess() string { + return component.VStackC( + component.T("Add New Wallet - Success").Bold(true).Primary(), + component.SpacerV(1), + component.T("✓ Wallet added successfully!").Success(), + component.SpacerV(1), + component.T("Your new wallet is ready to use.").Muted(), + ).Render() +} + +func (m Model) renderError() string { + return component.VStackC( + component.T("Add New Wallet - Error").Bold(true).Primary(), + component.SpacerV(1), + component.T("✗ Failed to add wallet").Error(), + component.SpacerV(1), + component.T("Error: "+m.errorMsg).Error(), + component.SpacerV(1), + component.T("Please try again or contact support if the problem persists.").Muted(), + ).Render() +} diff --git a/app/evm/wallet/add/page_test.go b/app/evm/wallet/add/page_test.go new file mode 100644 index 0000000..8171f4e --- /dev/null +++ b/app/evm/wallet/add/page_test.go @@ -0,0 +1,700 @@ +package add + +import ( + "fmt" + "math/big" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/rxtech-lab/smart-contract-cli/internal/config" + models "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/models/evm" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/sql" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/wallet" + "github.com/rxtech-lab/smart-contract-cli/internal/storage" + "github.com/rxtech-lab/smart-contract-cli/internal/view" + "github.com/stretchr/testify/suite" + "go.uber.org/mock/gomock" +) + +// WalletAddPageTestSuite is the test suite for the wallet add page. +type WalletAddPageTestSuite struct { + suite.Suite + ctrl *gomock.Controller + + router *view.MockRouter + sharedMemory storage.SharedMemory + walletService *wallet.MockWalletService + mockStorage *sql.MockStorage + model Model +} + +func TestWalletAddPageTestSuite(t *testing.T) { + suite.Run(t, new(WalletAddPageTestSuite)) +} + +func (suite *WalletAddPageTestSuite) SetupTest() { + suite.ctrl = gomock.NewController(suite.T()) + suite.router = view.NewMockRouter(suite.ctrl) + suite.sharedMemory = storage.NewSharedMemory() + suite.walletService = wallet.NewMockWalletService(suite.ctrl) + suite.mockStorage = sql.NewMockStorage(suite.ctrl) + + // Set up mock storage in shared memory with endpoint configured + testEndpoint := &models.EVMEndpoint{ + ID: 1, + Url: "http://localhost:8545", + } + testConfig := models.EVMConfig{ + ID: 1, + EndpointId: &testEndpoint.ID, + Endpoint: testEndpoint, + } + // Allow any number of calls to GetCurrentConfig - use a large number + suite.mockStorage.EXPECT().GetCurrentConfig().Return(testConfig, nil).AnyTimes() + + // Add mock storage to shared memory + err := suite.sharedMemory.Set(config.StorageClientKey, suite.mockStorage) + suite.NoError(err, "Should set storage client in shared memory") + + // Create model with mocked wallet service + page := NewPageWithService(suite.router, suite.sharedMemory, suite.walletService) + suite.model = page.(Model) +} + +func (suite *WalletAddPageTestSuite) TearDownTest() { + suite.ctrl.Finish() +} + +// TestCreateWalletWithPrivateKey tests the full flow of creating a wallet with a private key. +func (suite *WalletAddPageTestSuite) TestCreateWalletWithPrivateKey() { + // Step 1: Start at method selection + suite.Equal(stepSelectMethod, suite.model.currentStep) + suite.Equal(0, suite.model.selectedIndex) + + // Step 2: Select "Import from private key" (first option, index 0) + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.Equal(stepEnterAlias, suite.model.currentStep) + suite.Equal(methodPrivateKey, suite.model.method) + + // Step 3: Enter alias + suite.model.aliasInput.SetValue("test-wallet") + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.Equal(stepEnterPrivateKey, suite.model.currentStep) + + // Step 4: Enter private key and mock the service + testPrivateKey := "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + suite.model.pkeyInput.SetValue(testPrivateKey) + + // Mock wallet service expectations + suite.walletService.EXPECT().ValidatePrivateKey(testPrivateKey).Return(nil) + suite.walletService.EXPECT().WalletExistsByAlias("test-wallet").Return(false, nil) + + expectedWallet := &models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + } + suite.walletService.EXPECT().ImportPrivateKey("test-wallet", testPrivateKey).Return(expectedWallet, nil) + suite.walletService.EXPECT().WalletExistsByAddress(expectedWallet.Address).Return(false, nil) + + balance, _ := new(big.Int).SetString("10000000000000000000", 10) // 10 ETH + walletWithBalance := &wallet.WalletWithBalance{ + Wallet: *expectedWallet, + Balance: balance, + } + suite.walletService.EXPECT().GetWalletWithBalance(uint(1), "http://localhost:8545").Return(walletWithBalance, nil) + + // Trigger import + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + // Wait for import to complete (simulate async message) + importMsg := suite.model.importPrivateKey() + updatedModel, _ = suite.model.Update(importMsg) + suite.model = updatedModel.(Model) + + // Should be at confirmation step + suite.Equal(stepConfirm, suite.model.currentStep) + suite.NotNil(suite.model.confirmedWallet) + suite.Equal("test-wallet", suite.model.confirmedWallet.Wallet.Alias) + suite.Equal("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", suite.model.confirmedWallet.Wallet.Address) + + // Step 5: Confirm save (select first option "Save wallet") + suite.Equal(0, suite.model.selectedIndex) + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.Equal(stepSuccess, suite.model.currentStep) +} + +// TestCreateWalletWithMnemonic tests the full flow of creating a wallet with a mnemonic phrase. +func (suite *WalletAddPageTestSuite) TestCreateWalletWithMnemonic() { + // Step 1: Select "Import from mnemonic phrase" (second option, index 1) + suite.model.selectedIndex = 1 + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.Equal(stepEnterAlias, suite.model.currentStep) + suite.Equal(methodMnemonic, suite.model.method) + + // Step 2: Enter alias + suite.model.aliasInput.SetValue("mnemonic-wallet") + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.Equal(stepEnterMnemonic, suite.model.currentStep) + + // Step 3: Enter mnemonic + testMnemonic := "test test test test test test test test test test test junk" + suite.model.mnemonicInput.SetValue(testMnemonic) + + // Proceed to derivation path selection (Ctrl+S) + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + suite.model = updatedModel.(Model) + suite.Equal(stepSelectDerivationPath, suite.model.currentStep) + + // Step 4: Select default derivation path (first option) + suite.Equal(0, suite.model.selectedIndex) + + // Mock wallet service expectations + suite.walletService.EXPECT().ValidateMnemonic(testMnemonic).Return(nil) + suite.walletService.EXPECT().WalletExistsByAlias("mnemonic-wallet").Return(false, nil) + + derivationPath := "m/44'/60'/0'/0/0" + expectedWallet := &models.EVMWallet{ + ID: 2, + Alias: "mnemonic-wallet", + Address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + DerivationPath: &derivationPath, + } + suite.walletService.EXPECT().ImportMnemonic("mnemonic-wallet", testMnemonic, derivationPath).Return(expectedWallet, nil) + suite.walletService.EXPECT().WalletExistsByAddress(expectedWallet.Address).Return(false, nil) + + balance, _ := new(big.Int).SetString("5000000000000000000", 10) // 5 ETH + walletWithBalance := &wallet.WalletWithBalance{ + Wallet: *expectedWallet, + Balance: balance, + } + suite.walletService.EXPECT().GetWalletWithBalance(uint(2), "http://localhost:8545").Return(walletWithBalance, nil) + + // Trigger import + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + // Wait for import to complete + importMsg := suite.model.importMnemonic() + updatedModel, _ = suite.model.Update(importMsg) + suite.model = updatedModel.(Model) + + // Should be at confirmation step + suite.Equal(stepConfirm, suite.model.currentStep) + suite.NotNil(suite.model.confirmedWallet) + suite.Equal("mnemonic-wallet", suite.model.confirmedWallet.Wallet.Alias) + suite.Equal("0x70997970C51812dc3A010C7d01b50e0d17dc79C8", suite.model.confirmedWallet.Wallet.Address) + suite.NotNil(suite.model.confirmedWallet.Wallet.DerivationPath) + suite.Equal(derivationPath, *suite.model.confirmedWallet.Wallet.DerivationPath) + + // Step 5: Confirm save + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.Equal(stepSuccess, suite.model.currentStep) +} + +// TestGenerateNewWallet tests the full flow of generating a new random wallet. +func (suite *WalletAddPageTestSuite) TestGenerateNewWallet() { + // Step 1: Select "Generate new wallet" (third option, index 2) + suite.model.selectedIndex = 2 + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.Equal(stepEnterAlias, suite.model.currentStep) + suite.Equal(methodGenerate, suite.model.method) + + // Step 2: Enter alias + suite.model.aliasInput.SetValue("generated-wallet") + + // Mock wallet service expectations + suite.walletService.EXPECT().WalletExistsByAlias("generated-wallet").Return(false, nil) + + generatedMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + generatedPrivateKey := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + expectedWallet := &models.EVMWallet{ + ID: 3, + Alias: "generated-wallet", + Address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + } + suite.walletService.EXPECT().GenerateWallet("generated-wallet").Return( + expectedWallet, + generatedMnemonic, + generatedPrivateKey, + nil, + ) + + balance := big.NewInt(0) // New wallet, no balance + walletWithBalance := &wallet.WalletWithBalance{ + Wallet: *expectedWallet, + Balance: balance, + } + suite.walletService.EXPECT().GetWalletWithBalance(uint(3), "http://localhost:8545").Return(walletWithBalance, nil) + + // Trigger generation (pressing Enter will call generateWallet) + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.Equal(stepGenerating, suite.model.currentStep) + + // Wait for generation to complete + genMsg := suite.model.generateWallet() + updatedModel, _ = suite.model.Update(genMsg) + suite.model = updatedModel.(Model) + + // Should be at backup screen + suite.Equal(stepShowBackup, suite.model.currentStep) + suite.NotNil(suite.model.confirmedWallet) + suite.Equal(generatedMnemonic, suite.model.generatedMnemonic) + suite.Equal(generatedPrivateKey, suite.model.generatedPKey) + suite.Equal("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", suite.model.generatedAddress) + + // Step 3: Confirm backup saved (select first option) + suite.Equal(0, suite.model.selectedIndex) + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.Equal(stepSuccess, suite.model.currentStep) +} + +// TestPrivateKeyValidationError tests error handling when private key is invalid. +func (suite *WalletAddPageTestSuite) TestPrivateKeyValidationError() { + // Navigate to private key entry + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.model.aliasInput.SetValue("test-wallet") + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + // Enter invalid private key + invalidKey := "invalid-key" + suite.model.pkeyInput.SetValue(invalidKey) + + suite.walletService.EXPECT().ValidatePrivateKey(invalidKey).Return(fmt.Errorf("invalid private key")) + + // Trigger import + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + importMsg := suite.model.importPrivateKey() + updatedModel, _ = suite.model.Update(importMsg) + suite.model = updatedModel.(Model) + + // Should show error + suite.Equal(stepError, suite.model.currentStep) + suite.Contains(suite.model.errorMsg, "invalid private key format") +} + +// TestMnemonicValidationError tests error handling when mnemonic is invalid. +func (suite *WalletAddPageTestSuite) TestMnemonicValidationError() { + // Navigate to mnemonic entry + suite.model.selectedIndex = 1 + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.model.aliasInput.SetValue("test-wallet") + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + // Enter invalid mnemonic (only 5 words) + invalidMnemonic := "word1 word2 word3 word4 word5" + suite.model.mnemonicInput.SetValue(invalidMnemonic) + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + suite.model = updatedModel.(Model) + + suite.walletService.EXPECT().ValidateMnemonic(invalidMnemonic).Return(fmt.Errorf("invalid mnemonic")) + + // Trigger import + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + importMsg := suite.model.importMnemonic() + updatedModel, _ = suite.model.Update(importMsg) + suite.model = updatedModel.(Model) + + // Should show error + suite.Equal(stepError, suite.model.currentStep) + suite.Contains(suite.model.errorMsg, "invalid mnemonic phrase") + suite.Contains(suite.model.errorMsg, "got 5 words") +} + +// TestDuplicateAliasError tests error handling when alias already exists. +func (suite *WalletAddPageTestSuite) TestDuplicateAliasError() { + // Navigate to private key entry + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.model.aliasInput.SetValue("existing-wallet") + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + testPrivateKey := "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + suite.model.pkeyInput.SetValue(testPrivateKey) + + suite.walletService.EXPECT().ValidatePrivateKey(testPrivateKey).Return(nil) + suite.walletService.EXPECT().WalletExistsByAlias("existing-wallet").Return(true, nil) + + // Trigger import + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + importMsg := suite.model.importPrivateKey() + updatedModel, _ = suite.model.Update(importMsg) + suite.model = updatedModel.(Model) + + // Should show error + suite.Equal(stepError, suite.model.currentStep) + suite.Contains(suite.model.errorMsg, "wallet with alias 'existing-wallet' already exists") +} + +// TestDuplicateAddressError tests error handling when wallet address already exists. +func (suite *WalletAddPageTestSuite) TestDuplicateAddressError() { + // Navigate to private key entry + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.model.aliasInput.SetValue("test-wallet") + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + testPrivateKey := "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + suite.model.pkeyInput.SetValue(testPrivateKey) + + suite.walletService.EXPECT().ValidatePrivateKey(testPrivateKey).Return(nil) + suite.walletService.EXPECT().WalletExistsByAlias("test-wallet").Return(false, nil) + + expectedWallet := &models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + } + suite.walletService.EXPECT().ImportPrivateKey("test-wallet", testPrivateKey).Return(expectedWallet, nil) + suite.walletService.EXPECT().WalletExistsByAddress(expectedWallet.Address).Return(true, nil) + suite.walletService.EXPECT().DeleteWallet(uint(1)).Return(nil) + + // Trigger import + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + importMsg := suite.model.importPrivateKey() + updatedModel, _ = suite.model.Update(importMsg) + suite.model = updatedModel.(Model) + + // Should show error + suite.Equal(stepError, suite.model.currentStep) + suite.Contains(suite.model.errorMsg, "wallet already exists with address") +} + +// TestCancelAtConfirmation tests canceling wallet creation at confirmation step. +func (suite *WalletAddPageTestSuite) TestCancelAtConfirmation() { + // Setup: create a wallet and get to confirmation step + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.model.aliasInput.SetValue("test-wallet") + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + testPrivateKey := "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + suite.model.pkeyInput.SetValue(testPrivateKey) + + suite.walletService.EXPECT().ValidatePrivateKey(testPrivateKey).Return(nil) + suite.walletService.EXPECT().WalletExistsByAlias("test-wallet").Return(false, nil) + + expectedWallet := &models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + } + suite.walletService.EXPECT().ImportPrivateKey("test-wallet", testPrivateKey).Return(expectedWallet, nil) + suite.walletService.EXPECT().WalletExistsByAddress(expectedWallet.Address).Return(false, nil) + + balance, _ := new(big.Int).SetString("10000000000000000000", 10) + walletWithBalance := &wallet.WalletWithBalance{ + Wallet: *expectedWallet, + Balance: balance, + } + suite.walletService.EXPECT().GetWalletWithBalance(uint(1), "http://localhost:8545").Return(walletWithBalance, nil) + + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + importMsg := suite.model.importPrivateKey() + updatedModel, _ = suite.model.Update(importMsg) + suite.model = updatedModel.(Model) + + suite.Equal(stepConfirm, suite.model.currentStep) + + // Select "Cancel" option (index 1) + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyDown}) + suite.model = updatedModel.(Model) + suite.Equal(1, suite.model.selectedIndex) + + // Mock deletion + suite.walletService.EXPECT().DeleteWallet(uint(1)).Return(nil) + + // Confirm cancellation - this will trigger navigation via command + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + // Note: Navigation happens via tea.Cmd, not direct call +} + +// TestCancelGeneratedWalletAtBackup tests canceling a generated wallet at backup step. +func (suite *WalletAddPageTestSuite) TestCancelGeneratedWalletAtBackup() { + // Setup: generate a wallet and get to backup step + suite.model.selectedIndex = 2 + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.model.aliasInput.SetValue("generated-wallet") + + suite.walletService.EXPECT().WalletExistsByAlias("generated-wallet").Return(false, nil) + + expectedWallet := &models.EVMWallet{ + ID: 3, + Alias: "generated-wallet", + Address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + } + suite.walletService.EXPECT().GenerateWallet("generated-wallet").Return( + expectedWallet, + "test mnemonic phrase", + "0xprivatekey", + nil, + ) + + balance := big.NewInt(0) + walletWithBalance := &wallet.WalletWithBalance{ + Wallet: *expectedWallet, + Balance: balance, + } + suite.walletService.EXPECT().GetWalletWithBalance(uint(3), "http://localhost:8545").Return(walletWithBalance, nil) + + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + genMsg := suite.model.generateWallet() + updatedModel, _ = suite.model.Update(genMsg) + suite.model = updatedModel.(Model) + + suite.Equal(stepShowBackup, suite.model.currentStep) + + // Select "Cancel" option (index 1) + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyDown}) + suite.model = updatedModel.(Model) + suite.Equal(1, suite.model.selectedIndex) + + // Mock deletion + suite.walletService.EXPECT().DeleteWallet(uint(3)).Return(nil) + + // Confirm cancellation - this will trigger navigation via command + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + // Note: Navigation happens via tea.Cmd, not direct call +} + +// TestCustomDerivationPath tests using a custom derivation path with mnemonic. +func (suite *WalletAddPageTestSuite) TestCustomDerivationPath() { + // Navigate to mnemonic import + suite.model.selectedIndex = 1 + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.model.aliasInput.SetValue("custom-path-wallet") + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + testMnemonic := "test test test test test test test test test test test junk" + suite.model.mnemonicInput.SetValue(testMnemonic) + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + suite.model = updatedModel.(Model) + + // Navigate to custom path option (last option) + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyDown}) + suite.model = updatedModel.(Model) + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyDown}) + suite.model = updatedModel.(Model) + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyDown}) + suite.model = updatedModel.(Model) + suite.Equal(3, suite.model.selectedIndex) // Custom path is at index 3 + + // Enter custom path + customPath := "m/44'/60'/1'/0/0" + suite.model.customPathInput.SetValue(customPath) + + suite.walletService.EXPECT().ValidateMnemonic(testMnemonic).Return(nil) + suite.walletService.EXPECT().WalletExistsByAlias("custom-path-wallet").Return(false, nil) + + expectedWallet := &models.EVMWallet{ + ID: 4, + Alias: "custom-path-wallet", + Address: "0x90F79bf6EB2c4f870365E785982E1f101E93b906", + DerivationPath: &customPath, + } + suite.walletService.EXPECT().ImportMnemonic("custom-path-wallet", testMnemonic, customPath).Return(expectedWallet, nil) + suite.walletService.EXPECT().WalletExistsByAddress(expectedWallet.Address).Return(false, nil) + + balance, _ := new(big.Int).SetString("1000000000000000000", 10) + walletWithBalance := &wallet.WalletWithBalance{ + Wallet: *expectedWallet, + Balance: balance, + } + suite.walletService.EXPECT().GetWalletWithBalance(uint(4), "http://localhost:8545").Return(walletWithBalance, nil) + + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + importMsg := suite.model.importMnemonic() + updatedModel, _ = suite.model.Update(importMsg) + suite.model = updatedModel.(Model) + + suite.Equal(stepConfirm, suite.model.currentStep) + suite.NotNil(suite.model.confirmedWallet.Wallet.DerivationPath) + suite.Equal(customPath, *suite.model.confirmedWallet.Wallet.DerivationPath) +} + +// TestEmptyAliasError tests error when alias is empty. +func (suite *WalletAddPageTestSuite) TestEmptyAliasError() { + // Navigate to private key entry without entering alias + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + // Don't set alias, leave it empty + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + testPrivateKey := "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + suite.model.pkeyInput.SetValue(testPrivateKey) + + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + importMsg := suite.model.importPrivateKey() + updatedModel, _ = suite.model.Update(importMsg) + suite.model = updatedModel.(Model) + + suite.Equal(stepError, suite.model.currentStep) + suite.Contains(suite.model.errorMsg, "alias cannot be empty") +} + +// TestEmptyPrivateKeyError tests error when private key is empty. +func (suite *WalletAddPageTestSuite) TestEmptyPrivateKeyError() { + // Navigate to private key entry + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.model.aliasInput.SetValue("test-wallet") + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + // Don't set private key, leave it empty + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + importMsg := suite.model.importPrivateKey() + updatedModel, _ = suite.model.Update(importMsg) + suite.model = updatedModel.(Model) + + suite.Equal(stepError, suite.model.currentStep) + suite.Contains(suite.model.errorMsg, "private key cannot be empty") +} + +// TestNavigationKeys tests up/down/esc navigation. +func (suite *WalletAddPageTestSuite) TestNavigationKeys() { + // Test up/down navigation in method selection + suite.Equal(0, suite.model.selectedIndex) + + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyDown}) + suite.model = updatedModel.(Model) + suite.Equal(1, suite.model.selectedIndex) + + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyDown}) + suite.model = updatedModel.(Model) + suite.Equal(2, suite.model.selectedIndex) + + // Can't go down past last option + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyDown}) + suite.model = updatedModel.(Model) + suite.Equal(2, suite.model.selectedIndex) + + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyUp}) + suite.model = updatedModel.(Model) + suite.Equal(1, suite.model.selectedIndex) + + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyUp}) + suite.model = updatedModel.(Model) + suite.Equal(0, suite.model.selectedIndex) + + // Can't go up past first option + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyUp}) + suite.model = updatedModel.(Model) + suite.Equal(0, suite.model.selectedIndex) +} + +// TestEscNavigation tests ESC key navigation to go back. +func (suite *WalletAddPageTestSuite) TestEscNavigation() { + // Go to alias entry + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + suite.Equal(stepEnterAlias, suite.model.currentStep) + + // Press ESC to go back + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + suite.model = updatedModel.(Model) + suite.Equal(stepSelectMethod, suite.model.currentStep) +} + +// TestViewRendering tests that all view rendering methods return non-empty strings. +func (suite *WalletAddPageTestSuite) TestViewRendering() { + // Test each step's view rendering + suite.model.currentStep = stepSelectMethod + view := suite.model.View() + suite.NotEmpty(view) + suite.Contains(view, "Add New Wallet") + + suite.model.currentStep = stepEnterAlias + view = suite.model.View() + suite.NotEmpty(view) + + suite.model.currentStep = stepEnterPrivateKey + view = suite.model.View() + suite.NotEmpty(view) + suite.Contains(view, "Private Key") + + suite.model.currentStep = stepEnterMnemonic + view = suite.model.View() + suite.NotEmpty(view) + suite.Contains(view, "Mnemonic") + + suite.model.currentStep = stepSelectDerivationPath + view = suite.model.View() + suite.NotEmpty(view) + suite.Contains(view, "Derivation Path") + + suite.model.currentStep = stepGenerating + view = suite.model.View() + suite.NotEmpty(view) + suite.Contains(view, "Generating") + + suite.model.currentStep = stepSuccess + view = suite.model.View() + suite.NotEmpty(view) + suite.Contains(view, "Success") + + suite.model.currentStep = stepError + view = suite.model.View() + suite.NotEmpty(view) + suite.Contains(view, "Error") +} + +// TestHelpText tests that help text is provided for all steps. +func (suite *WalletAddPageTestSuite) TestHelpText() { + suite.model.currentStep = stepSelectMethod + help, _ := suite.model.Help() + suite.NotEmpty(help) + + suite.model.currentStep = stepEnterAlias + help, _ = suite.model.Help() + suite.NotEmpty(help) + suite.Contains(help, "enter") + suite.Contains(help, "esc") + + suite.model.currentStep = stepEnterPrivateKey + help, _ = suite.model.Help() + suite.NotEmpty(help) + + suite.model.currentStep = stepEnterMnemonic + help, _ = suite.model.Help() + suite.NotEmpty(help) + suite.Contains(help, "ctrl+s") + + suite.model.currentStep = stepSuccess + help, _ = suite.model.Help() + suite.NotEmpty(help) +} diff --git a/app/evm/wallet/delete/page.go b/app/evm/wallet/delete/page.go new file mode 100644 index 0000000..2743c7d --- /dev/null +++ b/app/evm/wallet/delete/page.go @@ -0,0 +1,384 @@ +package deletewallet + +import ( + "fmt" + "math/big" + "strconv" + + tea "github.com/charmbracelet/bubbletea" + "github.com/rxtech-lab/smart-contract-cli/internal/config" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/sql" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/wallet" + "github.com/rxtech-lab/smart-contract-cli/internal/log" + "github.com/rxtech-lab/smart-contract-cli/internal/storage" + "github.com/rxtech-lab/smart-contract-cli/internal/ui/component" + "github.com/rxtech-lab/smart-contract-cli/internal/utils" + "github.com/rxtech-lab/smart-contract-cli/internal/view" +) + +var logger, _ = log.NewFileLogger("./logs/evm/wallet/delete.log") + +type confirmationOption struct { + label string + value bool +} + +type viewMode int + +const ( + modeConfirmation viewMode = iota + modeCannotDelete +) + +type Model struct { + router view.Router + sharedMemory storage.SharedMemory + walletService wallet.WalletService + + walletID uint + wallet *wallet.WalletWithBalance + selectedWalletID uint + selectedIndex int + options []confirmationOption + mode viewMode + + loading bool + errorMsg string +} + +func NewPage(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(router, sharedMemory, nil) +} + +// NewPageWithService creates a new delete wallet page with an optional wallet service (for testing). +func NewPageWithService(router view.Router, sharedMemory storage.SharedMemory, walletService wallet.WalletService) view.View { + return Model{ + router: router, + sharedMemory: sharedMemory, + walletService: walletService, + loading: true, + mode: modeConfirmation, + selectedIndex: 0, + options: []confirmationOption{ + {label: "No, cancel", value: false}, + {label: "Yes, delete permanently", value: true}, + }, + } +} + +func (m Model) Init() tea.Cmd { + return m.loadWallet +} + +func (m Model) createWalletService() (wallet.WalletService, error) { + storageClient, err := m.sharedMemory.Get(config.StorageClientKey) + if err != nil || storageClient == nil { + logger.Error("Failed to get storage client from shared memory: %v", err) + return nil, fmt.Errorf("storage client not initialized") + } + + sqlStorage, isValidStorage := storageClient.(sql.Storage) + if !isValidStorage { + logger.Error("Invalid storage client type") + return nil, fmt.Errorf("invalid storage client type") + } + + secureStorage, _, err := utils.GetSecureStorageFromSharedMemory(m.sharedMemory) + if err != nil { + logger.Error("Failed to get secure storage from shared memory: %v", err) + return nil, fmt.Errorf("failed to get secure storage from shared memory: %w", err) + } + + return wallet.NewWalletService(sqlStorage, secureStorage), nil +} + +func (m Model) loadWallet() tea.Msg { + // Get wallet ID from query params + walletIDStr := m.router.GetQueryParam("id") + if walletIDStr == "" { + return walletLoadedMsg{err: fmt.Errorf("wallet ID not provided")} + } + + walletID, err := strconv.ParseUint(walletIDStr, 10, 32) + if err != nil { + return walletLoadedMsg{err: fmt.Errorf("invalid wallet ID: %w", err)} + } + + // Use injected wallet service if available (for testing) + walletService := m.walletService + if walletService == nil { + svc, err := m.createWalletService() + if err != nil { + return walletLoadedMsg{err: err} + } + walletService = svc + } + + rpcEndpoint := "http://localhost:8545" + + // Get wallet with balance + walletData, err := walletService.GetWalletWithBalance(uint(walletID), rpcEndpoint) + if err != nil { + return walletLoadedMsg{err: fmt.Errorf("failed to load wallet: %w", err)} + } + + // Get selected wallet ID from shared memory + selectedWalletIDVal, _ := m.sharedMemory.Get(config.SelectedWalletIDKey) + var selectedWalletID uint + if selectedWalletIDVal != nil { + if id, ok := selectedWalletIDVal.(uint); ok { + selectedWalletID = id + } + } + + return walletLoadedMsg{ + walletID: uint(walletID), + wallet: walletData, + walletService: walletService, + selectedWalletID: selectedWalletID, + } +} + +type walletLoadedMsg struct { + walletID uint + wallet *wallet.WalletWithBalance + walletService wallet.WalletService + selectedWalletID uint + err error +} + +type walletDeletedMsg struct { + success bool + err error +} + +func (m Model) deleteWallet() tea.Msg { + err := m.walletService.DeleteWallet(m.walletID) + if err != nil { + return walletDeletedMsg{success: false, err: err} + } + return walletDeletedMsg{success: true} +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case walletLoadedMsg: + if msg.err != nil { + m.loading = false + m.errorMsg = msg.err.Error() + return m, nil + } + + m.loading = false + m.walletID = msg.walletID + m.wallet = msg.wallet + m.walletService = msg.walletService + m.selectedWalletID = msg.selectedWalletID + + // Check if trying to delete currently selected wallet + if m.walletID == m.selectedWalletID { + m.mode = modeCannotDelete + } else { + m.mode = modeConfirmation + } + + return m, nil + + case walletDeletedMsg: + if msg.err != nil { + m.errorMsg = msg.err.Error() + return m, nil + } + + if msg.success { + logger.Info("Wallet deleted successfully: %d", m.walletID) + // Navigate back to wallet list + return m, func() tea.Msg { + _ = m.router.NavigateTo("/evm/wallet", nil) + return nil + } + } + + case tea.KeyMsg: + if m.loading { + return m, nil + } + + switch m.mode { + case modeCannotDelete: + // Any key goes back + return m, func() tea.Msg { + _ = m.router.NavigateTo("/evm/wallet", nil) + return nil + } + + case modeConfirmation: + switch msg.String() { + case "up", "k": + if m.selectedIndex > 0 { + m.selectedIndex-- + } + + case "down", "j": + if m.selectedIndex < len(m.options)-1 { + m.selectedIndex++ + } + + case "enter": + option := m.options[m.selectedIndex] + if option.value { + // User confirmed, delete wallet + return m, m.deleteWallet + } else { + // User cancelled, go back + return m, func() tea.Msg { + _ = m.router.NavigateTo("/evm/wallet", nil) + return nil + } + } + } + } + } + + return m, nil +} + +func (m Model) Help() (string, view.HelpDisplayOption) { + if m.loading { + return "Loading...", view.HelpDisplayOptionOverride + } + + switch m.mode { + case modeCannotDelete: + return "Press any key to go back...", view.HelpDisplayOptionOverride + default: + return "↑/k: up • ↓/j: down • enter: confirm • esc: cancel", view.HelpDisplayOptionAppend + } +} + +func (m Model) View() string { + if m.loading { + return component.VStackC( + component.T("Delete Wallet").Bold(true).Primary(), + component.SpacerV(1), + component.T("Loading wallet...").Muted(), + ).Render() + } + + if m.errorMsg != "" { + return component.VStackC( + component.T("Delete Wallet").Bold(true).Primary(), + component.SpacerV(1), + component.T("Error: "+m.errorMsg).Error(), + component.SpacerV(1), + component.T("Press 'esc' to go back").Muted(), + ).Render() + } + + if m.wallet == nil { + return component.VStackC( + component.T("Delete Wallet").Bold(true).Primary(), + component.SpacerV(1), + component.T("Wallet not found").Error(), + ).Render() + } + + switch m.mode { + case modeCannotDelete: + return m.renderCannotDelete() + default: + return m.renderConfirmation() + } +} + +func (m Model) renderConfirmation() string { + // Format balance + balanceStr := "unavailable" + if m.wallet.Error == nil && m.wallet.Balance != nil { + ethValue := new(big.Float).Quo( + new(big.Float).SetInt(m.wallet.Balance), + new(big.Float).SetInt(big.NewInt(1e18)), + ) + balanceStr = fmt.Sprintf("%.4f ETH", ethValue) + } + + // Build options + optionComponents := make([]component.Component, 0) + for i, option := range m.options { + isCursor := i == m.selectedIndex + + prefix := " " + if isCursor { + prefix = "> " + } + + labelStyle := component.T(prefix + option.label) + if isCursor { + labelStyle = labelStyle.Bold(true) + } + + var description string + if option.value { + description = "Remove this wallet from the system" + } else { + description = "Keep this wallet" + } + + optionComponents = append(optionComponents, component.VStackC( + labelStyle, + component.T(" "+description).Muted(), + component.SpacerV(1), + )) + } + + return component.VStackC( + component.T("Delete Wallet").Bold(true).Primary(), + component.SpacerV(1), + component.T("Are you sure you want to delete this wallet?").Bold(true), + component.SpacerV(1), + component.T("Wallet Information:").Bold(true), + component.T("• Alias: "+m.wallet.Wallet.Alias).Muted(), + component.T("• Address: "+m.wallet.Wallet.Address).Muted(), + component.T("• Balance: "+balanceStr).Muted(), + component.SpacerV(1), + component.T("⚠ Warning: This action cannot be undone!").Warning(), + component.SpacerV(1), + component.T("Make sure you have:"), + component.T("• Backed up your private key or mnemonic").Muted(), + component.T("• Transferred funds to another wallet").Muted(), + component.T("• No pending transactions").Muted(), + component.SpacerV(1), + component.VStackC(optionComponents...), + ).Render() +} + +func (m Model) renderCannotDelete() string { + // Format balance + balanceStr := "unavailable" + if m.wallet.Error == nil && m.wallet.Balance != nil { + ethValue := new(big.Float).Quo( + new(big.Float).SetInt(m.wallet.Balance), + new(big.Float).SetInt(big.NewInt(1e18)), + ) + balanceStr = fmt.Sprintf("%.4f ETH", ethValue) + } + + return component.VStackC( + component.T("Delete Wallet").Bold(true).Primary(), + component.SpacerV(1), + component.T("Cannot delete currently selected wallet!").Error(), + component.SpacerV(1), + component.T("Wallet Information:").Bold(true), + component.T("• Alias: "+m.wallet.Wallet.Alias+" ★").Muted(), + component.T("• Address: "+m.wallet.Wallet.Address).Muted(), + component.T("• Balance: "+balanceStr).Muted(), + component.T("• Status: Currently Selected").Muted(), + component.SpacerV(1), + component.T("To delete this wallet:").Bold(true), + component.T("1. Select another wallet as active"), + component.T("2. Return to this wallet"), + component.T("3. Then delete it"), + component.SpacerV(1), + component.T("This prevents accidentally losing access to your active wallet.").Muted(), + ).Render() +} diff --git a/app/evm/wallet/delete/page_test.go b/app/evm/wallet/delete/page_test.go new file mode 100644 index 0000000..388c383 --- /dev/null +++ b/app/evm/wallet/delete/page_test.go @@ -0,0 +1,598 @@ +package deletewallet + +import ( + "fmt" + "math/big" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/rxtech-lab/smart-contract-cli/internal/config" + models "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/models/evm" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/wallet" + "github.com/rxtech-lab/smart-contract-cli/internal/storage" + "github.com/rxtech-lab/smart-contract-cli/internal/view" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +// MockRouter implements view.Router interface. +type MockRouter struct { + mock.Mock +} + +func (m *MockRouter) AddRoute(route view.Route) { + m.Called(route) +} + +func (m *MockRouter) SetRoutes(routes []view.Route) { + m.Called(routes) +} + +func (m *MockRouter) RemoveRoute(path string) { + m.Called(path) +} + +func (m *MockRouter) GetRoutes() []view.Route { + args := m.Called() + return args.Get(0).([]view.Route) +} + +func (m *MockRouter) GetCurrentRoute() view.Route { + args := m.Called() + return args.Get(0).(view.Route) +} + +func (m *MockRouter) NavigateTo(path string, queryParams map[string]string) error { + args := m.Called(path, queryParams) + return args.Error(0) //nolint:wrapcheck // Mock method +} + +func (m *MockRouter) ReplaceRoute(path string) error { + args := m.Called(path) + return args.Error(0) //nolint:wrapcheck // Mock method +} + +func (m *MockRouter) Back() { + m.Called() +} + +func (m *MockRouter) CanGoBack() bool { + args := m.Called() + return args.Bool(0) +} + +func (m *MockRouter) GetParam(key string) string { + args := m.Called(key) + return args.String(0) +} + +func (m *MockRouter) GetQueryParam(key string) string { + args := m.Called(key) + return args.String(0) +} + +func (m *MockRouter) GetPath() string { + args := m.Called() + return args.String(0) +} + +func (m *MockRouter) Refresh() { + m.Called() +} + +func (m *MockRouter) Init() tea.Cmd { + args := m.Called() + if args.Get(0) == nil { + return nil + } + return args.Get(0).(tea.Cmd) +} + +func (m *MockRouter) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + args := m.Called(msg) + return args.Get(0).(tea.Model), nil +} + +func (m *MockRouter) View() string { + args := m.Called() + return args.String(0) +} + +// MockWalletService implements wallet.WalletService interface. +type MockWalletService struct { + mock.Mock +} + +func (m *MockWalletService) ImportPrivateKey(alias, privateKey string) (*models.EVMWallet, error) { + args := m.Called(alias, privateKey) + if args.Get(0) == nil { + return nil, args.Error(1) //nolint:wrapcheck // Mock method + } + return args.Get(0).(*models.EVMWallet), args.Error(1) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) ImportMnemonic(alias, mnemonic, derivationPath string) (*models.EVMWallet, error) { + args := m.Called(alias, mnemonic, derivationPath) + if args.Get(0) == nil { + return nil, args.Error(1) //nolint:wrapcheck // Mock method + } + return args.Get(0).(*models.EVMWallet), args.Error(1) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) GenerateWallet(alias string) (*models.EVMWallet, string, string, error) { + args := m.Called(alias) + if args.Get(0) == nil { + return nil, "", "", args.Error(3) //nolint:wrapcheck // Mock method + } + return args.Get(0).(*models.EVMWallet), args.String(1), args.String(2), args.Error(3) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) GetWalletWithBalance(walletID uint, rpcEndpoint string) (*wallet.WalletWithBalance, error) { + args := m.Called(walletID, rpcEndpoint) + if args.Get(0) == nil { + return nil, args.Error(1) //nolint:wrapcheck // Mock method + } + return args.Get(0).(*wallet.WalletWithBalance), args.Error(1) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) ListWalletsWithBalances(page int64, pageSize int64, rpcEndpoint string) ([]wallet.WalletWithBalance, int64, error) { + args := m.Called(page, pageSize, rpcEndpoint) + if args.Get(0) == nil { + return nil, 0, args.Error(2) //nolint:wrapcheck // Mock method + } + return args.Get(0).([]wallet.WalletWithBalance), args.Get(1).(int64), args.Error(2) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) GetWallet(walletID uint) (*models.EVMWallet, error) { + args := m.Called(walletID) + if args.Get(0) == nil { + return nil, args.Error(1) //nolint:wrapcheck // Mock method + } + return args.Get(0).(*models.EVMWallet), args.Error(1) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) DeleteWallet(walletID uint) error { + args := m.Called(walletID) + return args.Error(0) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) WalletExistsByAlias(alias string) (bool, error) { + args := m.Called(alias) + return args.Bool(0), args.Error(1) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) WalletExistsByAddress(address string) (bool, error) { + args := m.Called(address) + return args.Bool(0), args.Error(1) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) ValidatePrivateKey(privateKey string) error { + args := m.Called(privateKey) + return args.Error(0) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) ValidateMnemonic(mnemonic string) error { + args := m.Called(mnemonic) + return args.Error(0) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) GetPrivateKey(walletID uint) (string, error) { + args := m.Called(walletID) + return args.String(0), args.Error(1) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) GetMnemonic(walletID uint) (string, error) { + args := m.Called(walletID) + return args.String(0), args.Error(1) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) UpdateWalletAlias(walletID uint, newAlias string) error { + args := m.Called(walletID, newAlias) + return args.Error(0) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) UpdateWalletPrivateKey(walletID uint, newPrivateKeyHex string) error { + args := m.Called(walletID, newPrivateKeyHex) + return args.Error(0) //nolint:wrapcheck // Mock method +} + +// WalletDeletePageTestSuite is the test suite for the wallet delete page. +type WalletDeletePageTestSuite struct { + suite.Suite + router *MockRouter + sharedMemory storage.SharedMemory + walletService *MockWalletService + model Model +} + +func TestWalletDeletePageTestSuite(t *testing.T) { + suite.Run(t, new(WalletDeletePageTestSuite)) +} + +func (suite *WalletDeletePageTestSuite) SetupTest() { + suite.router = new(MockRouter) + suite.sharedMemory = storage.NewSharedMemory() + suite.walletService = new(MockWalletService) + + // Create model with mocked wallet service + page := NewPageWithService(suite.router, suite.sharedMemory, suite.walletService) + suite.model = page.(Model) +} + +func (suite *WalletDeletePageTestSuite) TearDownTest() { + suite.router.AssertExpectations(suite.T()) + suite.walletService.AssertExpectations(suite.T()) +} + +// TestDeleteNonSelectedWallet tests deleting a wallet that is not currently selected. +func (suite *WalletDeletePageTestSuite) TestDeleteNonSelectedWallet() { + // Mock router to return wallet ID + suite.router.On("GetQueryParam", "id").Return("1") + + // Mock wallet service + balance, _ := new(big.Int).SetString("5000000000000000000", 10) // 5 ETH + testWallet := &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }, + Balance: balance, + } + suite.walletService.On("GetWalletWithBalance", uint(1), "http://localhost:8545").Return(testWallet, nil) + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Verify state + suite.False(suite.model.loading) + suite.Equal(uint(1), suite.model.walletID) + suite.NotNil(suite.model.wallet) + suite.Equal("test-wallet", suite.model.wallet.Wallet.Alias) + suite.Equal(modeConfirmation, suite.model.mode) + + // Select "Yes, delete permanently" (index 1) + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyDown}) + suite.model = updatedModel.(Model) + suite.Equal(1, suite.model.selectedIndex) + + // Mock deletion + suite.walletService.On("DeleteWallet", uint(1)).Return(nil) + + // Confirm deletion + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + // Wait for deletion to complete + deleteMsg := suite.model.deleteWallet() + updatedModel, _ = suite.model.Update(deleteMsg) + suite.model = updatedModel.(Model) + + // Verify deletion was successful (navigation command is returned) +} + +// TestCannotDeleteSelectedWallet tests that the currently selected wallet cannot be deleted. +func (suite *WalletDeletePageTestSuite) TestCannotDeleteSelectedWallet() { + // Mock router to return wallet ID + suite.router.On("GetQueryParam", "id").Return("1") + + // Mock wallet service + balance, _ := new(big.Int).SetString("10000000000000000000", 10) // 10 ETH + testWallet := &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "selected-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }, + Balance: balance, + } + suite.walletService.On("GetWalletWithBalance", uint(1), "http://localhost:8545").Return(testWallet, nil) + + // Set this wallet as selected in shared memory + _ = suite.sharedMemory.Set(config.SelectedWalletIDKey, uint(1)) + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Verify state - should be in "cannot delete" mode + suite.False(suite.model.loading) + suite.Equal(uint(1), suite.model.walletID) + suite.Equal(uint(1), suite.model.selectedWalletID) + suite.Equal(modeCannotDelete, suite.model.mode) + + // Any key should navigate back + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) +} + +// TestCancelDeletion tests canceling the deletion process. +func (suite *WalletDeletePageTestSuite) TestCancelDeletion() { + // Mock router to return wallet ID + suite.router.On("GetQueryParam", "id").Return("2") + + // Mock wallet service + balance := big.NewInt(0) + testWallet := &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 2, + Alias: "wallet-to-cancel", + Address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + }, + Balance: balance, + } + suite.walletService.On("GetWalletWithBalance", uint(2), "http://localhost:8545").Return(testWallet, nil) + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Verify we're in confirmation mode + suite.Equal(modeConfirmation, suite.model.mode) + + // Select "No, cancel" (index 0 - default) + suite.Equal(0, suite.model.selectedIndex) + + // Press enter to cancel + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + // Should navigate back (returns a command) +} + +// TestInvalidWalletID tests error handling when wallet ID is invalid. +func (suite *WalletDeletePageTestSuite) TestInvalidWalletID() { + // Mock router to return invalid wallet ID + suite.router.On("GetQueryParam", "id").Return("invalid") + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Should show error + suite.False(suite.model.loading) + suite.NotEmpty(suite.model.errorMsg) + suite.Contains(suite.model.errorMsg, "invalid wallet ID") +} + +// TestMissingWalletID tests error handling when wallet ID is not provided. +func (suite *WalletDeletePageTestSuite) TestMissingWalletID() { + // Mock router to return empty wallet ID + suite.router.On("GetQueryParam", "id").Return("") + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Should show error + suite.False(suite.model.loading) + suite.NotEmpty(suite.model.errorMsg) + suite.Contains(suite.model.errorMsg, "wallet ID not provided") +} + +// TestWalletNotFound tests error handling when wallet doesn't exist. +func (suite *WalletDeletePageTestSuite) TestWalletNotFound() { + // Mock router to return wallet ID + suite.router.On("GetQueryParam", "id").Return("999") + + // Mock wallet service to return error + suite.walletService.On("GetWalletWithBalance", uint(999), "http://localhost:8545"). + Return(nil, fmt.Errorf("wallet not found")) + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Should show error + suite.False(suite.model.loading) + suite.NotEmpty(suite.model.errorMsg) + suite.Contains(suite.model.errorMsg, "failed to load wallet") +} + +// TestDeletionError tests error handling when deletion fails. +func (suite *WalletDeletePageTestSuite) TestDeletionError() { + // Mock router to return wallet ID + suite.router.On("GetQueryParam", "id").Return("3") + + // Mock wallet service + balance := big.NewInt(0) + testWallet := &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 3, + Alias: "test-wallet", + Address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + }, + Balance: balance, + } + suite.walletService.On("GetWalletWithBalance", uint(3), "http://localhost:8545").Return(testWallet, nil) + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Select "Yes, delete permanently" + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyDown}) + suite.model = updatedModel.(Model) + + // Mock deletion to fail + suite.walletService.On("DeleteWallet", uint(3)).Return(fmt.Errorf("database error")) + + // Confirm deletion + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + // Wait for deletion to complete + deleteMsg := suite.model.deleteWallet() + updatedModel, _ = suite.model.Update(deleteMsg) + suite.model = updatedModel.(Model) + + // Should show error + suite.NotEmpty(suite.model.errorMsg) + suite.Contains(suite.model.errorMsg, "database error") +} + +// TestNavigationKeys tests up/down navigation. +func (suite *WalletDeletePageTestSuite) TestNavigationKeys() { + // Mock router to return wallet ID + suite.router.On("GetQueryParam", "id").Return("1") + + // Mock wallet service + testWallet := &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }, + Balance: big.NewInt(0), + } + suite.walletService.On("GetWalletWithBalance", uint(1), "http://localhost:8545").Return(testWallet, nil) + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Test navigation + suite.Equal(0, suite.model.selectedIndex) // Starts at "No, cancel" + + // Navigate down to "Yes, delete" + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyDown}) + suite.model = updatedModel.(Model) + suite.Equal(1, suite.model.selectedIndex) + + // Can't go down past last option + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyDown}) + suite.model = updatedModel.(Model) + suite.Equal(1, suite.model.selectedIndex) + + // Navigate up + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyUp}) + suite.model = updatedModel.(Model) + suite.Equal(0, suite.model.selectedIndex) + + // Can't go up past first option + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyUp}) + suite.model = updatedModel.(Model) + suite.Equal(0, suite.model.selectedIndex) + + // Test vim keys (j/k) + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + suite.model = updatedModel.(Model) + suite.Equal(1, suite.model.selectedIndex) + + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + suite.model = updatedModel.(Model) + suite.Equal(0, suite.model.selectedIndex) +} + +// TestViewRendering tests that all view modes render correctly. +func (suite *WalletDeletePageTestSuite) TestViewRendering() { + // Test loading state + suite.model.loading = true + view := suite.model.View() + suite.NotEmpty(view) + suite.Contains(view, "Loading") + + // Test error state + suite.model.loading = false + suite.model.errorMsg = "Test error" + view = suite.model.View() + suite.NotEmpty(view) + suite.Contains(view, "Error") + suite.Contains(view, "Test error") + + // Test confirmation mode + suite.model.errorMsg = "" + suite.model.wallet = &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }, + Balance: big.NewInt(1000000000000000000), + } + suite.model.mode = modeConfirmation + view = suite.model.View() + suite.NotEmpty(view) + suite.Contains(view, "Delete Wallet") + suite.Contains(view, "test-wallet") + suite.Contains(view, "Are you sure") + + // Test cannot delete mode + suite.model.mode = modeCannotDelete + view = suite.model.View() + suite.NotEmpty(view) + suite.Contains(view, "Cannot delete currently selected wallet") + suite.Contains(view, "Select another wallet") +} + +// TestHelpText tests that help text is provided for all modes. +func (suite *WalletDeletePageTestSuite) TestHelpText() { + // Test loading state + suite.model.loading = true + help, _ := suite.model.Help() + suite.NotEmpty(help) + suite.Contains(help, "Loading") + + // Test cannot delete mode + suite.model.loading = false + suite.model.mode = modeCannotDelete + help, _ = suite.model.Help() + suite.NotEmpty(help) + suite.Contains(help, "Press any key") + + // Test confirmation mode + suite.model.mode = modeConfirmation + help, _ = suite.model.Help() + suite.NotEmpty(help) + suite.Contains(help, "enter") +} + +// TestBalanceFormatting tests that balance is formatted correctly in views. +func (suite *WalletDeletePageTestSuite) TestBalanceFormatting() { + suite.model.loading = false + suite.model.mode = modeConfirmation + + // Test with balance + balance, _ := new(big.Int).SetString("1234567890123456789", 10) + suite.model.wallet = &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }, + Balance: balance, + } + + view := suite.model.View() + suite.Contains(view, "1.2346 ETH") + + // Test with balance error + suite.model.wallet.Error = fmt.Errorf("RPC error") + suite.model.wallet.Balance = nil + view = suite.model.View() + suite.Contains(view, "unavailable") +} + +// TestInitialState tests that the model is initialized correctly. +func (suite *WalletDeletePageTestSuite) TestInitialState() { + page := NewPageWithService(suite.router, suite.sharedMemory, suite.walletService) + model := page.(Model) + + suite.True(model.loading) + suite.Equal(modeConfirmation, model.mode) + suite.Equal(0, model.selectedIndex) + suite.Len(model.options, 2) + suite.Equal("No, cancel", model.options[0].label) + suite.Equal("Yes, delete permanently", model.options[1].label) + suite.False(model.options[0].value) + suite.True(model.options[1].value) +} diff --git a/app/evm/wallet/details/page.go b/app/evm/wallet/details/page.go new file mode 100644 index 0000000..95a9e63 --- /dev/null +++ b/app/evm/wallet/details/page.go @@ -0,0 +1,412 @@ +package details + +import ( + "fmt" + "math/big" + "strconv" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/rxtech-lab/smart-contract-cli/internal/config" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/sql" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/wallet" + "github.com/rxtech-lab/smart-contract-cli/internal/log" + "github.com/rxtech-lab/smart-contract-cli/internal/storage" + "github.com/rxtech-lab/smart-contract-cli/internal/ui/component" + "github.com/rxtech-lab/smart-contract-cli/internal/utils" + "github.com/rxtech-lab/smart-contract-cli/internal/view" +) + +var logger, _ = log.NewFileLogger("./logs/evm/wallet/details.log") + +type viewMode int + +const ( + modeNormal viewMode = iota + modeShowPrivateKeyPrompt + modeShowPrivateKey +) + +type Model struct { + router view.Router + sharedMemory storage.SharedMemory + walletService wallet.WalletService + + walletID uint + wallet *wallet.WalletWithBalance + selectedWalletID uint + privateKey string + + mode viewMode + confirmationInput textinput.Model + autoCloseCounter int + + loading bool + errorMsg string +} + +func NewPage(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(router, sharedMemory, nil) +} + +// NewPageWithService creates a new details page with an optional wallet service (for testing). +func NewPageWithService(router view.Router, sharedMemory storage.SharedMemory, walletService wallet.WalletService) view.View { + confirmInput := textinput.New() + confirmInput.Placeholder = "Type 'SHOW' to confirm" + confirmInput.Width = 30 + + return Model{ + router: router, + sharedMemory: sharedMemory, + walletService: walletService, + loading: true, + mode: modeNormal, + confirmationInput: confirmInput, + } +} + +func (m Model) Init() tea.Cmd { + return m.loadWallet +} + +func (m Model) createWalletService() (wallet.WalletService, error) { + storageClient, err := m.sharedMemory.Get(config.StorageClientKey) + if err != nil || storageClient == nil { + logger.Error("Failed to get storage client from shared memory: %v", err) + return nil, fmt.Errorf("storage client not initialized") + } + + sqlStorage, isValidStorage := storageClient.(sql.Storage) + if !isValidStorage { + logger.Error("Invalid storage client type") + return nil, fmt.Errorf("invalid storage client type") + } + + secureStorage, _, err := utils.GetSecureStorageFromSharedMemory(m.sharedMemory) + if err != nil { + logger.Error("Failed to get secure storage from shared memory: %v", err) + return nil, fmt.Errorf("failed to get secure storage from shared memory: %w", err) + } + + return wallet.NewWalletService(sqlStorage, secureStorage), nil +} + +func (m Model) loadWallet() tea.Msg { + walletIDStr := m.router.GetQueryParam("id") + if walletIDStr == "" { + return walletLoadedMsg{err: fmt.Errorf("wallet ID not provided")} + } + + walletID, err := strconv.ParseUint(walletIDStr, 10, 32) + if err != nil { + return walletLoadedMsg{err: fmt.Errorf("invalid wallet ID: %w", err)} + } + + walletService := m.walletService + if walletService == nil { + svc, err := m.createWalletService() + if err != nil { + return walletLoadedMsg{err: err} + } + walletService = svc + } + + rpcEndpoint := "http://localhost:8545" + + walletData, err := walletService.GetWalletWithBalance(uint(walletID), rpcEndpoint) + if err != nil { + return walletLoadedMsg{err: fmt.Errorf("failed to load wallet: %w", err)} + } + + selectedWalletIDVal, _ := m.sharedMemory.Get(config.SelectedWalletIDKey) + var selectedWalletID uint + if selectedWalletIDVal != nil { + if id, ok := selectedWalletIDVal.(uint); ok { + selectedWalletID = id + } + } + + return walletLoadedMsg{ + walletID: uint(walletID), + wallet: walletData, + walletService: walletService, + selectedWalletID: selectedWalletID, + } +} + +type walletLoadedMsg struct { + walletID uint + wallet *wallet.WalletWithBalance + walletService wallet.WalletService + selectedWalletID uint + err error +} + +type privateKeyLoadedMsg struct { + privateKey string + err error +} + +type autoCloseTickMsg struct{} + +func (m Model) loadPrivateKey() tea.Msg { + privateKey, err := m.walletService.GetPrivateKey(m.walletID) + if err != nil { + return privateKeyLoadedMsg{err: err} + } + return privateKeyLoadedMsg{privateKey: privateKey} +} + +func autoCloseTick() tea.Msg { + time.Sleep(1 * time.Second) + return autoCloseTickMsg{} +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case walletLoadedMsg: + if msg.err != nil { + m.loading = false + m.errorMsg = msg.err.Error() + return m, nil + } + + m.loading = false + m.walletID = msg.walletID + m.wallet = msg.wallet + m.walletService = msg.walletService + m.selectedWalletID = msg.selectedWalletID + return m, nil + + case privateKeyLoadedMsg: + if msg.err != nil { + m.errorMsg = msg.err.Error() + m.mode = modeNormal + return m, nil + } + + m.privateKey = msg.privateKey + m.mode = modeShowPrivateKey + m.autoCloseCounter = 60 + return m, autoCloseTick + + case autoCloseTickMsg: + if m.mode == modeShowPrivateKey { + m.autoCloseCounter-- + if m.autoCloseCounter <= 0 { + m.mode = modeNormal + m.privateKey = "" + return m, nil + } + return m, autoCloseTick + } + + case tea.KeyMsg: + if m.loading { + return m, nil + } + + // Handle different modes + switch m.mode { + case modeShowPrivateKeyPrompt: + var cmd tea.Cmd + m.confirmationInput, cmd = m.confirmationInput.Update(msg) + + switch msg.String() { + case "enter": + if m.confirmationInput.Value() == "SHOW" { + m.confirmationInput.SetValue("") + return m, m.loadPrivateKey + } else { + m.errorMsg = "Incorrect confirmation. Type 'SHOW' exactly." + return m, nil + } + case "esc": + m.mode = modeNormal + m.confirmationInput.SetValue("") + return m, cmd + } + return m, cmd + + case modeShowPrivateKey: + switch msg.String() { + case "c": + // TODO: Copy to clipboard functionality + // For now, just show a message + logger.Info("Copy to clipboard requested") + case "esc", "q": + m.mode = modeNormal + m.privateKey = "" + } + + case modeNormal: + switch msg.String() { + case "p": + m.mode = modeShowPrivateKeyPrompt + m.confirmationInput.Focus() + return m, textinput.Blink + case "r": + // Refresh balance + m.loading = true + return m, m.loadWallet + } + } + } + + return m, nil +} + +func (m Model) Help() (string, view.HelpDisplayOption) { + if m.loading { + return "Loading...", view.HelpDisplayOptionOverride + } + + switch m.mode { + case modeShowPrivateKeyPrompt: + return "enter: confirm • esc: cancel", view.HelpDisplayOptionOverride + case modeShowPrivateKey: + return "c: copy to clipboard • esc/q: close immediately", view.HelpDisplayOptionOverride + default: + return "r: refresh balance • p: show private key • esc/q: back", view.HelpDisplayOptionAppend + } +} + +func (m Model) View() string { + if m.loading { + return component.VStackC( + component.T("Wallet Details").Bold(true).Primary(), + component.SpacerV(1), + component.T("Loading wallet...").Muted(), + ).Render() + } + + if m.errorMsg != "" { + return component.VStackC( + component.T("Wallet Details").Bold(true).Primary(), + component.SpacerV(1), + component.T("Error: "+m.errorMsg).Error(), + component.SpacerV(1), + component.T("Press 'esc' to go back").Muted(), + ).Render() + } + + if m.wallet == nil { + return component.VStackC( + component.T("Wallet Details").Bold(true).Primary(), + component.SpacerV(1), + component.T("Wallet not found").Error(), + ).Render() + } + + // Show different views based on mode + switch m.mode { + case modeShowPrivateKeyPrompt: + return m.renderPrivateKeyPrompt() + case modeShowPrivateKey: + return m.renderPrivateKey() + default: + return m.renderDetails() + } +} + +func (m Model) renderDetails() string { + // Format balance + balanceStr := "unavailable ⚠" + usdValue := "N/A" + if m.wallet.Error == nil && m.wallet.Balance != nil { + ethValue := new(big.Float).Quo( + new(big.Float).SetInt(m.wallet.Balance), + new(big.Float).SetInt(big.NewInt(1e18)), + ) + balanceStr = fmt.Sprintf("%.4f ETH", ethValue) + + // Mock USD value (in production, fetch from price oracle) + ethFloat, _ := ethValue.Float64() + usdValue = fmt.Sprintf("$%.2f (at $2,000/ETH)", ethFloat*2000) + } + + // Status indicator + statusStr := "Available" + if m.walletID == m.selectedWalletID { + statusStr = "★ Currently Selected" + } + + title := "Wallet Details - " + m.wallet.Wallet.Alias + + return component.VStackC( + component.T(title).Bold(true).Primary(), + component.SpacerV(1), + component.T("Alias: "+m.wallet.Wallet.Alias), + component.T("Status: "+statusStr).Muted(), + component.SpacerV(1), + + component.T("Address Information:").Bold(true), + component.T("• Address: "+m.wallet.Wallet.Address).Muted(), + component.T("• Checksum: ✓ Valid Ethereum address").Muted(), + component.IfC( + m.wallet.Wallet.DerivationPath != nil && *m.wallet.Wallet.DerivationPath != "", + component.T("• Derivation Path: "+*m.wallet.Wallet.DerivationPath).Muted(), + component.Empty(), + ), + component.SpacerV(1), + + component.T("Balance Information:").Bold(true), + component.T("• Balance: "+balanceStr).Muted(), + component.T("• USD Value: "+usdValue).Muted(), + component.T("• Endpoint: http://localhost:8545 (Anvil)").Muted(), + component.T("• Last Updated: "+time.Now().Format("2006-01-02 3:04 PM")).Muted(), + component.SpacerV(1), + + component.T("Security:").Bold(true), + component.T("• Private Key: ******** (hidden)").Muted(), + component.T("• Created: "+m.wallet.Wallet.CreatedAt.Format("2006-01-02 3:04 PM")).Muted(), + component.T("• Last Modified: "+m.wallet.Wallet.UpdatedAt.Format("2006-01-02 3:04 PM")).Muted(), + component.IfC( + m.wallet.Wallet.IsFromMnemonic, + component.T("• Mnemonic: Available (use 'm' to show)").Muted(), + component.Empty(), + ), + ).Render() +} + +func (m Model) renderPrivateKeyPrompt() string { + return component.VStackC( + component.T("Show Private Key - Security Warning").Bold(true).Warning(), + component.SpacerV(1), + component.T("⚠ WARNING: Exposing Sensitive Information!").Warning(), + component.SpacerV(1), + component.T("You are about to reveal the private key for:"), + component.T("• Alias: "+m.wallet.Wallet.Alias).Muted(), + component.T("• Address: "+m.wallet.Wallet.Address).Muted(), + component.SpacerV(1), + component.T("⚠ Anyone with this private key can access and control your funds!").Warning(), + component.T("⚠ Make sure no one is watching your screen!").Warning(), + component.T("⚠ Be careful when sharing screenshots or recordings!").Warning(), + component.SpacerV(1), + component.T("Type \"SHOW\" to reveal the private key (case sensitive):"), + component.SpacerV(1), + component.T("Confirmation: "+m.confirmationInput.View()), + ).Render() +} + +func (m Model) renderPrivateKey() string { + return component.VStackC( + component.T("Show Private Key - "+m.wallet.Wallet.Alias).Bold(true).Warning(), + component.SpacerV(1), + component.T("⚠ SENSITIVE INFORMATION - KEEP SECURE! ⚠").Warning(), + component.SpacerV(1), + component.T("Wallet: "+m.wallet.Wallet.Alias), + component.T("Address: "+m.wallet.Wallet.Address).Muted(), + component.SpacerV(1), + component.T("Private Key:"), + component.T("────────────────────────────────────────────────────────────────────────────").Muted(), + component.T(m.privateKey), + component.T("────────────────────────────────────────────────────────────────────────────").Muted(), + component.SpacerV(1), + component.T("⚠ NEVER share this with anyone!").Warning(), + component.T("⚠ Anyone with this key can control your funds!").Warning(), + component.SpacerV(1), + component.T(fmt.Sprintf("This screen will automatically close in %d seconds...", m.autoCloseCounter)).Muted(), + ).Render() +} diff --git a/app/evm/wallet/details/page_test.go b/app/evm/wallet/details/page_test.go new file mode 100644 index 0000000..85378be --- /dev/null +++ b/app/evm/wallet/details/page_test.go @@ -0,0 +1,696 @@ +package details + +import ( + "fmt" + "math/big" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/rxtech-lab/smart-contract-cli/internal/config" + models "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/models/evm" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/wallet" + "github.com/rxtech-lab/smart-contract-cli/internal/storage" + "github.com/rxtech-lab/smart-contract-cli/internal/view" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +// MockRouter implements view.Router interface. +type MockRouter struct { + mock.Mock +} + +func (m *MockRouter) AddRoute(route view.Route) { + m.Called(route) +} + +func (m *MockRouter) SetRoutes(routes []view.Route) { + m.Called(routes) +} + +func (m *MockRouter) RemoveRoute(path string) { + m.Called(path) +} + +func (m *MockRouter) GetCurrentRoute() view.Route { + args := m.Called() + return args.Get(0).(view.Route) //nolint:forcetypeassert // Mock method +} + +func (m *MockRouter) GetRoutes() []view.Route { + args := m.Called() + return args.Get(0).([]view.Route) //nolint:forcetypeassert // Mock method +} + +func (m *MockRouter) NavigateTo(path string, queryParams map[string]string) error { + args := m.Called(path, queryParams) + return args.Error(0) //nolint:wrapcheck // Mock method +} + +func (m *MockRouter) ReplaceRoute(path string) error { + args := m.Called(path) + return args.Error(0) //nolint:wrapcheck // Mock method +} + +func (m *MockRouter) Back() { + m.Called() +} + +func (m *MockRouter) CanGoBack() bool { + args := m.Called() + return args.Bool(0) +} + +func (m *MockRouter) GetQueryParam(key string) string { + args := m.Called(key) + return args.String(0) +} + +func (m *MockRouter) GetParam(key string) string { + args := m.Called(key) + return args.String(0) +} + +func (m *MockRouter) GetPath() string { + args := m.Called() + return args.String(0) +} + +func (m *MockRouter) Refresh() { + m.Called() +} + +func (m *MockRouter) View() string { + args := m.Called() + return args.String(0) +} + +func (m *MockRouter) Init() tea.Cmd { + args := m.Called() + if args.Get(0) == nil { + return nil + } + return args.Get(0).(tea.Cmd) //nolint:forcetypeassert // Mock method +} + +func (m *MockRouter) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + args := m.Called(msg) + return args.Get(0).(tea.Model), nil //nolint:forcetypeassert // Mock method +} + +// MockWalletService implements wallet.WalletService interface. +type MockWalletService struct { + mock.Mock +} + +func (m *MockWalletService) ImportPrivateKey(alias, privateKey string) (*models.EVMWallet, error) { + args := m.Called(alias, privateKey) + if args.Get(0) == nil { + return nil, args.Error(1) //nolint:wrapcheck // Mock method + } + return args.Get(0).(*models.EVMWallet), args.Error(1) //nolint:wrapcheck,forcetypeassert // Mock method +} + +func (m *MockWalletService) ImportMnemonic(alias, mnemonic, derivationPath string) (*models.EVMWallet, error) { + args := m.Called(alias, mnemonic, derivationPath) + if args.Get(0) == nil { + return nil, args.Error(1) //nolint:wrapcheck // Mock method + } + return args.Get(0).(*models.EVMWallet), args.Error(1) //nolint:wrapcheck,forcetypeassert // Mock method +} + +func (m *MockWalletService) GenerateWallet(alias string) (*models.EVMWallet, string, string, error) { + args := m.Called(alias) + if args.Get(0) == nil { + return nil, "", "", args.Error(3) //nolint:wrapcheck // Mock method + } + return args.Get(0).(*models.EVMWallet), args.String(1), args.String(2), args.Error(3) //nolint:wrapcheck,forcetypeassert // Mock method +} + +func (m *MockWalletService) GetWalletWithBalance(walletID uint, rpcEndpoint string) (*wallet.WalletWithBalance, error) { + args := m.Called(walletID, rpcEndpoint) + if args.Get(0) == nil { + return nil, args.Error(1) //nolint:wrapcheck // Mock method + } + return args.Get(0).(*wallet.WalletWithBalance), args.Error(1) //nolint:wrapcheck,forcetypeassert // Mock method +} + +func (m *MockWalletService) ListWalletsWithBalances(page int64, pageSize int64, rpcEndpoint string) ([]wallet.WalletWithBalance, int64, error) { + args := m.Called(page, pageSize, rpcEndpoint) + if args.Get(0) == nil { + return nil, 0, args.Error(2) //nolint:wrapcheck // Mock method + } + return args.Get(0).([]wallet.WalletWithBalance), args.Get(1).(int64), args.Error(2) //nolint:wrapcheck,forcetypeassert // Mock method +} + +func (m *MockWalletService) GetWallet(walletID uint) (*models.EVMWallet, error) { + args := m.Called(walletID) + if args.Get(0) == nil { + return nil, args.Error(1) //nolint:wrapcheck // Mock method + } + return args.Get(0).(*models.EVMWallet), args.Error(1) //nolint:wrapcheck,forcetypeassert // Mock method +} + +func (m *MockWalletService) DeleteWallet(walletID uint) error { + args := m.Called(walletID) + return args.Error(0) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) WalletExistsByAlias(alias string) (bool, error) { + args := m.Called(alias) + return args.Bool(0), args.Error(1) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) WalletExistsByAddress(address string) (bool, error) { + args := m.Called(address) + return args.Bool(0), args.Error(1) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) ValidatePrivateKey(privateKey string) error { + args := m.Called(privateKey) + return args.Error(0) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) ValidateMnemonic(mnemonic string) error { + args := m.Called(mnemonic) + return args.Error(0) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) GetPrivateKey(walletID uint) (string, error) { + args := m.Called(walletID) + return args.String(0), args.Error(1) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) GetMnemonic(walletID uint) (string, error) { + args := m.Called(walletID) + return args.String(0), args.Error(1) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) UpdateWalletAlias(walletID uint, newAlias string) error { + args := m.Called(walletID, newAlias) + return args.Error(0) //nolint:wrapcheck // Mock method +} + +func (m *MockWalletService) UpdateWalletPrivateKey(walletID uint, newPrivateKeyHex string) error { + args := m.Called(walletID, newPrivateKeyHex) + return args.Error(0) //nolint:wrapcheck // Mock method +} + +// WalletDetailsPageTestSuite is the test suite for wallet details page. +type WalletDetailsPageTestSuite struct { + suite.Suite + router *MockRouter + sharedMemory storage.SharedMemory + walletService *MockWalletService + model Model +} + +func TestWalletDetailsPageTestSuite(t *testing.T) { + suite.Run(t, new(WalletDetailsPageTestSuite)) +} + +func (suite *WalletDetailsPageTestSuite) SetupTest() { + suite.router = new(MockRouter) + suite.sharedMemory = storage.NewSharedMemory() + suite.walletService = new(MockWalletService) + + // Set up shared memory with config + _ = suite.sharedMemory.Set(config.SelectedWalletIDKey, uint(2)) + + // Create model with mocked dependencies + page := NewPageWithService(suite.router, suite.sharedMemory, suite.walletService) + suite.model = page.(Model) +} + +func (suite *WalletDetailsPageTestSuite) TearDownTest() { + suite.router.AssertExpectations(suite.T()) + suite.walletService.AssertExpectations(suite.T()) +} + +// TestLoadWalletDetails tests loading wallet details successfully. +func (suite *WalletDetailsPageTestSuite) TestLoadWalletDetails() { + // Mock router to return wallet ID + suite.router.On("GetQueryParam", "id").Return("1") + + // Mock wallet service + balance, _ := new(big.Int).SetString("5000000000000000000", 10) // 5 ETH + derivationPath := "m/44'/60'/0'/0/0" + testWallet := &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "my-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + IsFromMnemonic: true, + DerivationPath: &derivationPath, + CreatedAt: time.Now().Add(-24 * time.Hour), + UpdatedAt: time.Now(), + }, + Balance: balance, + } + suite.walletService.On("GetWalletWithBalance", uint(1), "http://localhost:8545").Return(testWallet, nil) + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Verify state + suite.False(suite.model.loading) + suite.Equal(uint(1), suite.model.walletID) + suite.Equal(uint(2), suite.model.selectedWalletID) + suite.Equal("my-wallet", suite.model.wallet.Wallet.Alias) + suite.Equal(modeNormal, suite.model.mode) + + // Verify view contains expected information + view := suite.model.View() + suite.Contains(view, "my-wallet") + suite.Contains(view, "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266") + suite.Contains(view, "5.0000 ETH") + suite.Contains(view, "m/44'/60'/0'/0/0") +} + +// TestMissingWalletID tests error handling when wallet ID is not provided. +func (suite *WalletDetailsPageTestSuite) TestMissingWalletID() { + // Mock router to return empty wallet ID + suite.router.On("GetQueryParam", "id").Return("") + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Verify error state + suite.False(suite.model.loading) + suite.Contains(suite.model.errorMsg, "wallet ID not provided") +} + +// TestInvalidWalletID tests error handling when wallet ID is invalid. +func (suite *WalletDetailsPageTestSuite) TestInvalidWalletID() { + // Mock router to return invalid wallet ID + suite.router.On("GetQueryParam", "id").Return("invalid") + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Verify error state + suite.False(suite.model.loading) + suite.Contains(suite.model.errorMsg, "invalid wallet ID") +} + +// TestWalletNotFound tests error handling when wallet is not found. +func (suite *WalletDetailsPageTestSuite) TestWalletNotFound() { + // Mock router to return wallet ID + suite.router.On("GetQueryParam", "id").Return("999") + + // Mock wallet service to return error + suite.walletService.On("GetWalletWithBalance", uint(999), "http://localhost:8545"). + Return(nil, fmt.Errorf("wallet not found")) + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Verify error state + suite.False(suite.model.loading) + suite.Contains(suite.model.errorMsg, "failed to load wallet") +} + +// TestShowPrivateKeyPrompt tests entering private key prompt mode. +func (suite *WalletDetailsPageTestSuite) TestShowPrivateKeyPrompt() { + // Setup wallet first + suite.router.On("GetQueryParam", "id").Return("1") + balance, _ := new(big.Int).SetString("1000000000000000000", 10) + testWallet := &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }, + Balance: balance, + } + suite.walletService.On("GetWalletWithBalance", uint(1), "http://localhost:8545").Return(testWallet, nil) + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Press 'p' to show private key prompt + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + suite.model = updatedModel.(Model) + + // Verify mode changed + suite.Equal(modeShowPrivateKeyPrompt, suite.model.mode) + + // Verify view shows warning + view := suite.model.View() + suite.Contains(view, "Security Warning") + suite.Contains(view, "WARNING") + suite.Contains(view, "Type \"SHOW\" to reveal") +} + +// TestCancelPrivateKeyPrompt tests canceling private key prompt. +func (suite *WalletDetailsPageTestSuite) TestCancelPrivateKeyPrompt() { + // Setup wallet and enter prompt mode + suite.router.On("GetQueryParam", "id").Return("1") + balance, _ := new(big.Int).SetString("1000000000000000000", 10) + testWallet := &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }, + Balance: balance, + } + suite.walletService.On("GetWalletWithBalance", uint(1), "http://localhost:8545").Return(testWallet, nil) + + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Enter prompt mode + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + suite.model = updatedModel.(Model) + + // Press 'esc' to cancel + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + suite.model = updatedModel.(Model) + + // Verify mode changed back to normal + suite.Equal(modeNormal, suite.model.mode) +} + +// TestShowPrivateKeyWithCorrectConfirmation tests showing private key with correct confirmation. +func (suite *WalletDetailsPageTestSuite) TestShowPrivateKeyWithCorrectConfirmation() { + // Setup wallet and enter prompt mode + suite.router.On("GetQueryParam", "id").Return("1") + balance, _ := new(big.Int).SetString("1000000000000000000", 10) + testWallet := &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }, + Balance: balance, + } + suite.walletService.On("GetWalletWithBalance", uint(1), "http://localhost:8545").Return(testWallet, nil) + + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Enter prompt mode + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + suite.model = updatedModel.(Model) + + // Type "SHOW" + suite.model.confirmationInput.SetValue("SHOW") + + // Mock private key loading + suite.walletService.On("GetPrivateKey", uint(1)). + Return("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", nil) + + // Press enter + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + // Handle private key loaded message + privateKeyMsg := suite.model.loadPrivateKey() + updatedModel, _ = suite.model.Update(privateKeyMsg) + suite.model = updatedModel.(Model) + + // Verify mode changed to show private key + suite.Equal(modeShowPrivateKey, suite.model.mode) + suite.Equal("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", suite.model.privateKey) + suite.Equal(60, suite.model.autoCloseCounter) + + // Verify view shows private key + view := suite.model.View() + suite.Contains(view, "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + suite.Contains(view, "SENSITIVE INFORMATION") + suite.Contains(view, "automatically close in 60 seconds") +} + +// TestShowPrivateKeyWithIncorrectConfirmation tests showing error with incorrect confirmation. +func (suite *WalletDetailsPageTestSuite) TestShowPrivateKeyWithIncorrectConfirmation() { + // Setup wallet and enter prompt mode + suite.router.On("GetQueryParam", "id").Return("1") + balance, _ := new(big.Int).SetString("1000000000000000000", 10) + testWallet := &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }, + Balance: balance, + } + suite.walletService.On("GetWalletWithBalance", uint(1), "http://localhost:8545").Return(testWallet, nil) + + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Enter prompt mode + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + suite.model = updatedModel.(Model) + + // Type wrong confirmation + suite.model.confirmationInput.SetValue("show") + + // Press enter + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + suite.model = updatedModel.(Model) + + // Verify error message + suite.Contains(suite.model.errorMsg, "Incorrect confirmation") + suite.Equal(modeShowPrivateKeyPrompt, suite.model.mode) +} + +// TestClosePrivateKeyView tests closing private key view manually. +func (suite *WalletDetailsPageTestSuite) TestClosePrivateKeyView() { + // Setup model in showPrivateKey mode + suite.model.loading = false + suite.model.mode = modeShowPrivateKey + suite.model.privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + suite.model.autoCloseCounter = 60 + balance, _ := new(big.Int).SetString("1000000000000000000", 10) + suite.model.wallet = &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }, + Balance: balance, + } + + // Press 'q' to close + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + suite.model = updatedModel.(Model) + + // Verify mode changed back to normal and private key cleared + suite.Equal(modeNormal, suite.model.mode) + suite.Equal("", suite.model.privateKey) +} + +// TestAutoClosePrivateKeyView tests auto-closing private key view after countdown. +func (suite *WalletDetailsPageTestSuite) TestAutoClosePrivateKeyView() { + // Setup model in showPrivateKey mode + suite.model.loading = false + suite.model.mode = modeShowPrivateKey + suite.model.privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + suite.model.autoCloseCounter = 1 + balance, _ := new(big.Int).SetString("1000000000000000000", 10) + suite.model.wallet = &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }, + Balance: balance, + } + + // Simulate auto-close tick + updatedModel, _ := suite.model.Update(autoCloseTickMsg{}) + suite.model = updatedModel.(Model) + + // Verify counter decremented and mode changed back to normal + suite.Equal(modeNormal, suite.model.mode) + suite.Equal("", suite.model.privateKey) +} + +// TestRefreshBalance tests refreshing wallet balance. +func (suite *WalletDetailsPageTestSuite) TestRefreshBalance() { + // Setup wallet first + suite.router.On("GetQueryParam", "id").Return("1").Times(2) + balance, _ := new(big.Int).SetString("1000000000000000000", 10) + testWallet := &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }, + Balance: balance, + } + suite.walletService.On("GetWalletWithBalance", uint(1), "http://localhost:8545").Return(testWallet, nil).Times(2) + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Press 'r' to refresh + updatedModel, _ = suite.model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + suite.model = updatedModel.(Model) + + // Verify loading state + suite.True(suite.model.loading) + + // Handle refresh message + loadMsg = suite.model.loadWallet() + updatedModel, _ = suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Verify wallet reloaded + suite.False(suite.model.loading) + suite.Equal(uint(1), suite.model.walletID) +} + +// TestViewRenderingWithSelectedWallet tests view rendering when wallet is selected. +func (suite *WalletDetailsPageTestSuite) TestViewRenderingWithSelectedWallet() { + // Setup wallet as selected + suite.router.On("GetQueryParam", "id").Return("2") + balance, _ := new(big.Int).SetString("3000000000000000000", 10) // 3 ETH + emptyPath := "" + now := time.Now() + testWallet := &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 2, + Alias: "selected-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + DerivationPath: &emptyPath, + CreatedAt: now, + UpdatedAt: now, + }, + Balance: balance, + } + suite.walletService.On("GetWalletWithBalance", uint(2), "http://localhost:8545").Return(testWallet, nil) + + // Load wallet + loadMsg := suite.model.loadWallet() + updatedModel, _ := suite.model.Update(loadMsg) + suite.model = updatedModel.(Model) + + // Verify view shows selected status + view := suite.model.View() + suite.Contains(view, "★ Currently Selected") +} + +// TestViewRenderingWithBalanceError tests view rendering when balance fetch fails. +func (suite *WalletDetailsPageTestSuite) TestViewRenderingWithBalanceError() { + // Setup wallet with balance error + suite.model.loading = false + suite.model.mode = modeNormal + suite.model.walletID = 1 + suite.model.selectedWalletID = 2 + emptyPath := "" + now := time.Now() + suite.model.wallet = &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + DerivationPath: &emptyPath, + CreatedAt: now, + UpdatedAt: now, + }, + Balance: nil, + Error: fmt.Errorf("RPC connection failed"), + } + + // Verify view shows balance unavailable + view := suite.model.View() + suite.Contains(view, "unavailable") +} + +// TestHelpText tests help text in different modes. +func (suite *WalletDetailsPageTestSuite) TestHelpText() { + // Test normal mode + suite.model.loading = false + suite.model.mode = modeNormal + helpText, _ := suite.model.Help() + suite.Contains(helpText, "refresh balance") + suite.Contains(helpText, "show private key") + + // Test private key prompt mode + suite.model.mode = modeShowPrivateKeyPrompt + helpText, _ = suite.model.Help() + suite.Contains(helpText, "confirm") + suite.Contains(helpText, "cancel") + + // Test show private key mode + suite.model.mode = modeShowPrivateKey + helpText, _ = suite.model.Help() + suite.Contains(helpText, "copy to clipboard") + suite.Contains(helpText, "close immediately") + + // Test loading mode + suite.model.loading = true + helpText, _ = suite.model.Help() + suite.Contains(helpText, "Loading") +} + +// TestInitialState tests that the model is initialized correctly. +func (suite *WalletDetailsPageTestSuite) TestInitialState() { + page := NewPageWithService(suite.router, suite.sharedMemory, suite.walletService) + model := page.(Model) + + suite.True(model.loading) + suite.Equal(modeNormal, model.mode) + suite.Equal("Type 'SHOW' to confirm", model.confirmationInput.Placeholder) +} + +// TestPrivateKeyLoadError tests error handling when loading private key fails. +func (suite *WalletDetailsPageTestSuite) TestPrivateKeyLoadError() { + // Setup model + suite.model.loading = false + suite.model.mode = modeShowPrivateKeyPrompt + suite.model.walletID = 1 + balance, _ := new(big.Int).SetString("1000000000000000000", 10) + suite.model.wallet = &wallet.WalletWithBalance{ + Wallet: models.EVMWallet{ + ID: 1, + Alias: "test-wallet", + Address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }, + Balance: balance, + } + + // Mock private key loading error + suite.walletService.On("GetPrivateKey", uint(1)).Return("", fmt.Errorf("decryption failed")) + + // Load private key + privateKeyMsg := suite.model.loadPrivateKey() + updatedModel, _ := suite.model.Update(privateKeyMsg) + suite.model = updatedModel.(Model) + + // Verify error state + suite.Contains(suite.model.errorMsg, "decryption failed") + suite.Equal(modeNormal, suite.model.mode) + suite.Equal("", suite.model.privateKey) +} + +// TestIgnoreKeysWhileLoading tests that keys are ignored while loading. +func (suite *WalletDetailsPageTestSuite) TestIgnoreKeysWhileLoading() { + suite.model.loading = true + + // Try pressing keys + updatedModel, _ := suite.model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + suite.model = updatedModel.(Model) + + // Verify nothing changed + suite.Equal(modeNormal, suite.model.mode) +} diff --git a/app/evm/wallet/page.go b/app/evm/wallet/page.go index 363f5cb..05b5c74 100644 --- a/app/evm/wallet/page.go +++ b/app/evm/wallet/page.go @@ -7,8 +7,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/rxtech-lab/smart-contract-cli/internal/config" - "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/sql" "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/wallet" "github.com/rxtech-lab/smart-contract-cli/internal/log" "github.com/rxtech-lab/smart-contract-cli/internal/storage" @@ -53,22 +51,11 @@ func (m Model) Init() tea.Cmd { } func (m Model) createWalletService() (wallet.WalletService, error) { - keys, err := m.sharedMemory.List() + // Get storage client from shared memory using utils + sqlStorage, err := utils.GetStorageClientFromSharedMemory(m.sharedMemory) if err != nil { - return nil, fmt.Errorf("failed to list shared memory keys: %w", err) - } - logger.Info("Shared memory keys: %v", keys) - // Get storage client from shared memory - storageClient, err := m.sharedMemory.Get(config.StorageClientKey) - if err != nil || storageClient == nil { logger.Error("Failed to get storage client from shared memory: %v", err) - return nil, fmt.Errorf("storage client not initialized") - } - - sqlStorage, isValidStorage := storageClient.(sql.Storage) - if !isValidStorage { - logger.Error("Invalid storage client type") - return nil, fmt.Errorf("invalid storage client type") + return nil, fmt.Errorf("failed to get storage client from shared memory: %w", err) } // Get secure storage @@ -92,16 +79,30 @@ func (m Model) loadWallets() tea.Msg { walletService = svc } - // Get RPC endpoint (simplified - using localhost for now) - rpcEndpoint := "http://localhost:8545" + // Get RPC endpoint from database + sqlStorage, err := utils.GetStorageClientFromSharedMemory(m.sharedMemory) + if err != nil { + logger.Error("Failed to get storage client from shared memory: %v", err) + return walletLoadedMsg{err: fmt.Errorf("failed to get storage client from shared memory: %w", err)} + } - // Get selected wallet ID from shared memory - selectedWalletIDVal, _ := m.sharedMemory.Get(config.SelectedWalletIDKey) + // Get the current config + config, err := sqlStorage.GetCurrentConfig() + if err != nil { + logger.Error("Failed to get current config: %v", err) + return walletLoadedMsg{err: fmt.Errorf("failed to get current config: %w", err)} + } + if config.Endpoint == nil { + logger.Error("No RPC endpoint configured") + return walletLoadedMsg{err: fmt.Errorf("no RPC endpoint configured. Please configure an endpoint first")} + } + rpcEndpoint := config.Endpoint.Url + logger.Info("Using RPC endpoint: %s", rpcEndpoint) + + // Get selected wallet ID from config var selectedWalletID uint - if selectedWalletIDVal != nil { - if id, ok := selectedWalletIDVal.(uint); ok { - selectedWalletID = id - } + if config.SelectedWalletID != nil { + selectedWalletID = *config.SelectedWalletID } // List wallets with balances @@ -169,12 +170,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Navigate to wallet actions if len(m.wallets) > 0 { walletID := m.wallets[m.selectedIndex].Wallet.ID - return m, func() tea.Msg { - _ = m.router.NavigateTo("/evm/wallet/actions", map[string]string{ - "id": strconv.FormatUint(uint64(walletID), 10), - }) - return nil + logger.Info("Enter pressed, navigating to wallet actions for wallet ID: %d", walletID) + err := m.router.NavigateTo("/evm/wallet/actions", map[string]string{ + "id": strconv.FormatUint(uint64(walletID), 10), + }) + if err != nil { + logger.Error("Navigation error: %v", err) } + return m, nil } case "a": diff --git a/app/evm/wallet/page_test.go b/app/evm/wallet/page_test.go index 0921849..1e18593 100644 --- a/app/evm/wallet/page_test.go +++ b/app/evm/wallet/page_test.go @@ -9,7 +9,9 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/exp/teatest" + "github.com/rxtech-lab/smart-contract-cli/internal/config" models "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/models/evm" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/sql" walletsvc "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/wallet" "github.com/rxtech-lab/smart-contract-cli/internal/storage" "github.com/rxtech-lab/smart-contract-cli/internal/view" @@ -72,9 +74,40 @@ func (s *WalletPageTestSuite) setupMockWalletService() *walletsvc.MockWalletServ return walletsvc.NewMockWalletService(s.mockCtrl) } +// setupMockStorage creates a mock storage client and sets up the shared memory with a default config. +func (s *WalletPageTestSuite) setupMockStorage(selectedWalletID *uint) { + mockStorage := sql.NewMockStorage(s.mockCtrl) + + // Create test config with default RPC endpoint + endpoint := &models.EVMEndpoint{ + ID: 1, + Url: "http://localhost:8545", + Name: "Test Endpoint", + } + + evmConfig := models.EVMConfig{ + ID: 1, + EndpointId: &endpoint.ID, + Endpoint: endpoint, + SelectedWalletID: selectedWalletID, + } + + // Set up mock to return config + mockStorage.EXPECT(). + GetCurrentConfig(). + Return(evmConfig, nil). + AnyTimes() + + // Store mock storage in shared memory as the Storage interface type + var storage sql.Storage = mockStorage + err := s.sharedMemory.Set(config.StorageClientKey, storage) + s.NoError(err, "Should set storage client in shared memory") +} + // TestEmptyWalletList tests the empty state when no wallets exist. func (s *WalletPageTestSuite) TestEmptyWalletList() { mockWalletSvc := s.setupMockWalletService() + s.setupMockStorage(nil) // Mock ListWalletsWithBalances to return empty list mockWalletSvc.EXPECT(). @@ -105,6 +138,8 @@ func (s *WalletPageTestSuite) TestEmptyWalletList() { // TestWalletListDisplay tests displaying a list of wallets with balances. func (s *WalletPageTestSuite) TestWalletListDisplay() { mockWalletSvc := s.setupMockWalletService() + walletID := uint(1) + s.setupMockStorage(&walletID) balance1 := new(big.Int) balance1.SetString("1000000000000000000", 10) // 1 ETH @@ -163,6 +198,7 @@ func (s *WalletPageTestSuite) TestWalletListDisplay() { // TestNavigationUpDown tests keyboard navigation through wallet list. func (s *WalletPageTestSuite) TestNavigationUpDown() { mockWalletSvc := s.setupMockWalletService() + s.setupMockStorage(nil) testWallets := []walletsvc.WalletWithBalance{ { @@ -224,6 +260,7 @@ func (s *WalletPageTestSuite) TestNavigationUpDown() { // TestNavigationWithVimKeys tests navigation using vim-style keys (j/k). func (s *WalletPageTestSuite) TestNavigationWithVimKeys() { mockWalletSvc := s.setupMockWalletService() + s.setupMockStorage(nil) testWallets := []walletsvc.WalletWithBalance{ { @@ -277,6 +314,7 @@ func (s *WalletPageTestSuite) TestNavigationWithVimKeys() { // TestRefreshWallets tests the refresh functionality with 'r' key. func (s *WalletPageTestSuite) TestRefreshWallets() { mockWalletSvc := s.setupMockWalletService() + s.setupMockStorage(nil) testWallets := []walletsvc.WalletWithBalance{ { @@ -321,6 +359,7 @@ func (s *WalletPageTestSuite) TestRefreshWallets() { // TestBackNavigation tests going back with 'esc' or 'q'. func (s *WalletPageTestSuite) TestBackNavigation() { mockWalletSvc := s.setupMockWalletService() + s.setupMockStorage(nil) mockWalletSvc.EXPECT(). ListWalletsWithBalances(int64(1), int64(100), "http://localhost:8545"). @@ -372,6 +411,7 @@ func (s *WalletPageTestSuite) TestBackNavigation() { // TestBalanceUnavailable tests display when balance is nil. func (s *WalletPageTestSuite) TestBalanceUnavailable() { mockWalletSvc := s.setupMockWalletService() + s.setupMockStorage(nil) testWallets := []walletsvc.WalletWithBalance{ { @@ -411,6 +451,7 @@ func (s *WalletPageTestSuite) TestBalanceUnavailable() { // TestHelpText tests that help text is displayed correctly. func (s *WalletPageTestSuite) TestHelpText() { mockWalletSvc := s.setupMockWalletService() + s.setupMockStorage(nil) mockWalletSvc.EXPECT(). ListWalletsWithBalances(int64(1), int64(100), "http://localhost:8545"). @@ -439,6 +480,7 @@ func (s *WalletPageTestSuite) TestHelpText() { // TestAddFirstWallet tests pressing 'a' to add first wallet when list is empty. func (s *WalletPageTestSuite) TestAddFirstWallet() { mockWalletSvc := s.setupMockWalletService() + s.setupMockStorage(nil) // Mock empty wallet list mockWalletSvc.EXPECT(). @@ -491,6 +533,7 @@ func (s *WalletPageTestSuite) TestAddFirstWallet() { // TestAddWalletFromNonEmptyList tests pressing 'a' when wallets already exist. func (s *WalletPageTestSuite) TestAddWalletFromNonEmptyList() { mockWalletSvc := s.setupMockWalletService() + s.setupMockStorage(nil) testWallets := []walletsvc.WalletWithBalance{ { diff --git a/app/evm/wallet/select/page.go b/app/evm/wallet/select/page.go new file mode 100644 index 0000000..5200f83 --- /dev/null +++ b/app/evm/wallet/select/page.go @@ -0,0 +1,421 @@ +package selectwallet + +import ( + "fmt" + "math/big" + "strconv" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/rxtech-lab/smart-contract-cli/internal/config" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/sql" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/wallet" + "github.com/rxtech-lab/smart-contract-cli/internal/log" + "github.com/rxtech-lab/smart-contract-cli/internal/storage" + "github.com/rxtech-lab/smart-contract-cli/internal/ui/component" + "github.com/rxtech-lab/smart-contract-cli/internal/utils" + "github.com/rxtech-lab/smart-contract-cli/internal/view" +) + +var logger, _ = log.NewFileLogger("./logs/evm/wallet/select.log") + +type confirmationOption struct { + label string + value bool +} + +type viewMode int + +const ( + modeConfirmation viewMode = iota + modeSuccess +) + +type Model struct { + router view.Router + sharedMemory storage.SharedMemory + walletService wallet.WalletService + + newWalletID uint + currentWalletID uint + newWallet *wallet.WalletWithBalance + currentWallet *wallet.WalletWithBalance + selectedIndex int + options []confirmationOption + mode viewMode + successMessage string + + loading bool + errorMsg string +} + +func NewPage(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(router, sharedMemory, nil) +} + +// NewPageWithService creates a new select wallet page with an optional wallet service (for testing). +func NewPageWithService(router view.Router, sharedMemory storage.SharedMemory, walletService wallet.WalletService) view.View { + return Model{ + router: router, + sharedMemory: sharedMemory, + walletService: walletService, + loading: true, + mode: modeConfirmation, + selectedIndex: 0, + options: []confirmationOption{ + {label: "Yes, switch wallet", value: true}, + {label: "No, cancel", value: false}, + }, + } +} + +func (m Model) Init() tea.Cmd { + return m.loadWallets +} + +func (m Model) createWalletService() (wallet.WalletService, error) { + storageClient, err := m.sharedMemory.Get(config.StorageClientKey) + if err != nil || storageClient == nil { + logger.Error("Failed to get storage client from shared memory: %v", err) + return nil, fmt.Errorf("storage client not initialized") + } + + sqlStorage, isValidStorage := storageClient.(sql.Storage) + if !isValidStorage { + logger.Error("Invalid storage client type") + return nil, fmt.Errorf("invalid storage client type") + } + + secureStorage, _, err := utils.GetSecureStorageFromSharedMemory(m.sharedMemory) + if err != nil { + logger.Error("Failed to get secure storage from shared memory: %v", err) + return nil, fmt.Errorf("failed to get secure storage from shared memory: %w", err) + } + + return wallet.NewWalletService(sqlStorage, secureStorage), nil +} + +func (m Model) loadWallets() tea.Msg { + // Get new wallet ID from query params + newWalletIDStr := m.router.GetQueryParam("id") + if newWalletIDStr == "" { + return walletsLoadedMsg{err: fmt.Errorf("wallet ID not provided")} + } + + newWalletID, err := strconv.ParseUint(newWalletIDStr, 10, 32) + if err != nil { + return walletsLoadedMsg{err: fmt.Errorf("invalid wallet ID: %w", err)} + } + + // Use injected wallet service if available (for testing) + walletService := m.walletService + if walletService == nil { + svc, err := m.createWalletService() + if err != nil { + return walletsLoadedMsg{err: err} + } + walletService = svc + } + + // Get current selected wallet ID from shared memory + currentWalletIDVal, _ := m.sharedMemory.Get(config.SelectedWalletIDKey) + var currentWalletID uint + if currentWalletIDVal != nil { + if id, ok := currentWalletIDVal.(uint); ok { + currentWalletID = id + } + } + + // If trying to select the same wallet, show error + if uint(newWalletID) == currentWalletID { + return walletsLoadedMsg{err: fmt.Errorf("wallet is already selected")} + } + + rpcEndpoint := "http://localhost:8545" + + // Load both wallets + newWallet, err := walletService.GetWalletWithBalance(uint(newWalletID), rpcEndpoint) + if err != nil { + return walletsLoadedMsg{err: fmt.Errorf("failed to load new wallet: %w", err)} + } + + var currentWallet *wallet.WalletWithBalance + if currentWalletID != 0 { + currentWallet, err = walletService.GetWalletWithBalance(currentWalletID, rpcEndpoint) + if err != nil { + logger.Warn("Failed to load current wallet: %v", err) + // Don't fail if current wallet can't be loaded, just continue + } + } + + return walletsLoadedMsg{ + newWalletID: uint(newWalletID), + currentWalletID: currentWalletID, + newWallet: newWallet, + currentWallet: currentWallet, + walletService: walletService, + } +} + +type walletsLoadedMsg struct { + newWalletID uint + currentWalletID uint + newWallet *wallet.WalletWithBalance + currentWallet *wallet.WalletWithBalance + walletService wallet.WalletService + err error +} + +type walletSelectedMsg struct { + success bool + err error +} + +func (m Model) selectWallet() tea.Msg { + // Update selected wallet in shared memory + err := m.sharedMemory.Set(config.SelectedWalletIDKey, m.newWalletID) + if err != nil { + return walletSelectedMsg{success: false, err: err} + } + + return walletSelectedMsg{success: true} +} + +type successDismissMsg struct{} + +func waitForDismiss() tea.Msg { + time.Sleep(2 * time.Second) + return successDismissMsg{} +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case walletsLoadedMsg: + if msg.err != nil { + m.loading = false + m.errorMsg = msg.err.Error() + return m, nil + } + + m.loading = false + m.newWalletID = msg.newWalletID + m.currentWalletID = msg.currentWalletID + m.newWallet = msg.newWallet + m.currentWallet = msg.currentWallet + m.walletService = msg.walletService + return m, nil + + case walletSelectedMsg: + if msg.err != nil { + m.errorMsg = msg.err.Error() + return m, nil + } + + if msg.success { + m.mode = modeSuccess + m.successMessage = "✓ Active wallet changed successfully!" + return m, waitForDismiss + } + + case successDismissMsg: + // Navigate back to wallet list + return m, func() tea.Msg { + _ = m.router.NavigateTo("/evm/wallet", nil) + return nil + } + + case tea.KeyMsg: + if m.loading { + return m, nil + } + + switch m.mode { + case modeSuccess: + // Any key dismisses success screen + return m, func() tea.Msg { + _ = m.router.NavigateTo("/evm/wallet", nil) + return nil + } + + case modeConfirmation: + switch msg.String() { + case "up", "k": + if m.selectedIndex > 0 { + m.selectedIndex-- + } + + case "down", "j": + if m.selectedIndex < len(m.options)-1 { + m.selectedIndex++ + } + + case "enter": + option := m.options[m.selectedIndex] + if option.value { + // User confirmed, switch wallet + return m, m.selectWallet + } else { + // User cancelled, go back + return m, func() tea.Msg { + _ = m.router.NavigateTo("/evm/wallet", nil) + return nil + } + } + } + } + } + + return m, nil +} + +func (m Model) Help() (string, view.HelpDisplayOption) { + if m.loading { + return "Loading...", view.HelpDisplayOptionOverride + } + + switch m.mode { + case modeSuccess: + return "Press any key to return to wallet list...", view.HelpDisplayOptionOverride + default: + return "↑/k: up • ↓/j: down • enter: confirm • esc: cancel", view.HelpDisplayOptionAppend + } +} + +func (m Model) View() string { + if m.loading { + return component.VStackC( + component.T("Select Active Wallet").Bold(true).Primary(), + component.SpacerV(1), + component.T("Loading wallets...").Muted(), + ).Render() + } + + if m.errorMsg != "" { + return component.VStackC( + component.T("Select Active Wallet").Bold(true).Primary(), + component.SpacerV(1), + component.T("Error: "+m.errorMsg).Error(), + component.SpacerV(1), + component.T("Press 'esc' to go back").Muted(), + ).Render() + } + + switch m.mode { + case modeSuccess: + return m.renderSuccess() + default: + return m.renderConfirmation() + } +} + +func (m Model) renderConfirmation() string { + // Format balances + currentBalance := "N/A" + if m.currentWallet != nil && m.currentWallet.Error == nil && m.currentWallet.Balance != nil { + ethValue := new(big.Float).Quo( + new(big.Float).SetInt(m.currentWallet.Balance), + new(big.Float).SetInt(big.NewInt(1e18)), + ) + currentBalance = fmt.Sprintf("%.4f ETH", ethValue) + } + + newBalance := "unavailable" + if m.newWallet.Error == nil && m.newWallet.Balance != nil { + ethValue := new(big.Float).Quo( + new(big.Float).SetInt(m.newWallet.Balance), + new(big.Float).SetInt(big.NewInt(1e18)), + ) + newBalance = fmt.Sprintf("%.4f ETH", ethValue) + } + + // Build current wallet display + var currentWalletDisplay component.Component + if m.currentWallet != nil { + currentWalletDisplay = component.VStackC( + component.T("Current Active Wallet:").Bold(true), + component.T("• Alias: "+m.currentWallet.Wallet.Alias+" ★").Muted(), + component.T("• Address: "+m.currentWallet.Wallet.Address).Muted(), + component.T("• Balance: "+currentBalance).Muted(), + component.SpacerV(1), + ) + } else { + currentWalletDisplay = component.VStackC( + component.T("Current Active Wallet: None").Bold(true), + component.SpacerV(1), + ) + } + + // Build options + optionComponents := make([]component.Component, 0) + for i, option := range m.options { + isCursor := i == m.selectedIndex + + prefix := " " + if isCursor { + prefix = "> " + } + + labelStyle := component.T(prefix + option.label) + if isCursor { + labelStyle = labelStyle.Bold(true) + } + + var description string + if option.value { + description = "Make " + m.newWallet.Wallet.Alias + " the active wallet" + } else { + if m.currentWallet != nil { + description = "Keep " + m.currentWallet.Wallet.Alias + " as active" + } else { + description = "Keep no wallet selected" + } + } + + optionComponents = append(optionComponents, component.VStackC( + labelStyle, + component.T(" "+description).Muted(), + component.SpacerV(1), + )) + } + + return component.VStackC( + component.T("Select Active Wallet").Bold(true).Primary(), + component.SpacerV(1), + component.T("Switch active wallet?"), + component.SpacerV(1), + currentWalletDisplay, + component.T("New Active Wallet:").Bold(true), + component.T("• Alias: "+m.newWallet.Wallet.Alias).Muted(), + component.T("• Address: "+m.newWallet.Wallet.Address).Muted(), + component.T("• Balance: "+newBalance).Muted(), + component.SpacerV(1), + component.T("All future transactions will use the new active wallet."), + component.SpacerV(1), + component.VStackC(optionComponents...), + ).Render() +} + +func (m Model) renderSuccess() string { + // Format balance + newBalance := "unavailable" + if m.newWallet.Error == nil && m.newWallet.Balance != nil { + ethValue := new(big.Float).Quo( + new(big.Float).SetInt(m.newWallet.Balance), + new(big.Float).SetInt(big.NewInt(1e18)), + ) + newBalance = fmt.Sprintf("%.4f ETH", ethValue) + } + + return component.VStackC( + component.T("Select Active Wallet").Bold(true).Primary(), + component.SpacerV(1), + component.T(m.successMessage).Success(), + component.SpacerV(1), + component.T("Your active wallet is now:").Bold(true), + component.T("• Alias: "+m.newWallet.Wallet.Alias+" ★").Muted(), + component.T("• Address: "+m.newWallet.Wallet.Address).Muted(), + component.T("• Balance: "+newBalance).Muted(), + component.SpacerV(1), + component.T("All transactions will now use this wallet."), + ).Render() +} diff --git a/app/evm/wallet/update/page.go b/app/evm/wallet/update/page.go new file mode 100644 index 0000000..957ebfa --- /dev/null +++ b/app/evm/wallet/update/page.go @@ -0,0 +1,593 @@ +package update + +import ( + "fmt" + "math/big" + "strconv" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/rxtech-lab/smart-contract-cli/internal/config" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/sql" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/wallet" + "github.com/rxtech-lab/smart-contract-cli/internal/log" + "github.com/rxtech-lab/smart-contract-cli/internal/storage" + "github.com/rxtech-lab/smart-contract-cli/internal/ui/component" + "github.com/rxtech-lab/smart-contract-cli/internal/utils" + "github.com/rxtech-lab/smart-contract-cli/internal/view" +) + +var logger, _ = log.NewFileLogger("./logs/evm/wallet/update.log") + +type updateStep int + +const ( + stepSelectUpdate updateStep = iota + stepUpdateAlias + stepPrivateKeyWarning + stepUpdatePrivateKey + stepSuccess +) + +type updateOption struct { + label string + description string + step updateStep +} + +type confirmationOption struct { + label string + value bool +} + +type Model struct { + router view.Router + sharedMemory storage.SharedMemory + walletService wallet.WalletService + + walletID uint + wallet *wallet.WalletWithBalance + currentStep updateStep + selectedIndex int + options []updateOption + confirmOpts []confirmationOption + + aliasInput textinput.Model + pkeyInput textinput.Model + + oldAddress string + newAddress string + updatedBalance string + + loading bool + errorMsg string +} + +func NewPage(router view.Router, sharedMemory storage.SharedMemory) view.View { + return NewPageWithService(router, sharedMemory, nil) +} + +// NewPageWithService creates a new update wallet page with an optional wallet service (for testing). +func NewPageWithService(router view.Router, sharedMemory storage.SharedMemory, walletService wallet.WalletService) view.View { + aliasInput := textinput.New() + aliasInput.Placeholder = "Enter new alias" + aliasInput.Width = 40 + + pkeyInput := textinput.New() + pkeyInput.Placeholder = "Enter private key (hex format)" + pkeyInput.EchoMode = textinput.EchoPassword + pkeyInput.EchoCharacter = '*' + pkeyInput.Width = 66 + + return Model{ + router: router, + sharedMemory: sharedMemory, + walletService: walletService, + loading: true, + currentStep: stepSelectUpdate, + selectedIndex: 0, + aliasInput: aliasInput, + pkeyInput: pkeyInput, + options: []updateOption{ + {label: "Update alias", description: "Change the wallet name", step: stepUpdateAlias}, + {label: "Update private key", description: "Replace the private key (will change address)", step: stepPrivateKeyWarning}, + {label: "Cancel", description: "Go back without changes", step: -1}, + }, + confirmOpts: []confirmationOption{ + {label: "No, cancel", value: false}, + {label: "Yes, update private key", value: true}, + }, + } +} + +func (m Model) Init() tea.Cmd { + return m.loadWallet +} + +func (m Model) createWalletService() (wallet.WalletService, error) { + storageClient, err := m.sharedMemory.Get(config.StorageClientKey) + if err != nil || storageClient == nil { + logger.Error("Failed to get storage client from shared memory: %v", err) + return nil, fmt.Errorf("storage client not initialized") + } + + sqlStorage, isValidStorage := storageClient.(sql.Storage) + if !isValidStorage { + logger.Error("Invalid storage client type") + return nil, fmt.Errorf("invalid storage client type") + } + + secureStorage, _, err := utils.GetSecureStorageFromSharedMemory(m.sharedMemory) + if err != nil { + logger.Error("Failed to get secure storage from shared memory: %v", err) + return nil, fmt.Errorf("failed to get secure storage from shared memory: %w", err) + } + + return wallet.NewWalletService(sqlStorage, secureStorage), nil +} + +func (m Model) loadWallet() tea.Msg { + walletIDStr := m.router.GetQueryParam("id") + if walletIDStr == "" { + return walletLoadedMsg{err: fmt.Errorf("wallet ID not provided")} + } + + walletID, err := strconv.ParseUint(walletIDStr, 10, 32) + if err != nil { + return walletLoadedMsg{err: fmt.Errorf("invalid wallet ID: %w", err)} + } + + walletService := m.walletService + if walletService == nil { + svc, err := m.createWalletService() + if err != nil { + return walletLoadedMsg{err: err} + } + walletService = svc + } + + rpcEndpoint := "http://localhost:8545" + + walletData, err := walletService.GetWalletWithBalance(uint(walletID), rpcEndpoint) + if err != nil { + return walletLoadedMsg{err: fmt.Errorf("failed to load wallet: %w", err)} + } + + return walletLoadedMsg{ + walletID: uint(walletID), + wallet: walletData, + walletService: walletService, + } +} + +type walletLoadedMsg struct { + walletID uint + wallet *wallet.WalletWithBalance + walletService wallet.WalletService + err error +} + +type walletUpdatedMsg struct { + success bool + updateType string + oldAddress string + newAddress string + newBalance *big.Int + err error +} + +func (m Model) updateAlias() tea.Msg { + newAlias := m.aliasInput.Value() + if newAlias == "" { + return walletUpdatedMsg{success: false, err: fmt.Errorf("alias cannot be empty")} + } + + err := m.walletService.UpdateWalletAlias(m.walletID, newAlias) + if err != nil { + return walletUpdatedMsg{success: false, err: err} + } + + return walletUpdatedMsg{success: true, updateType: "alias"} +} + +func (m Model) updatePrivateKey() tea.Msg { + newPrivateKey := m.pkeyInput.Value() + if newPrivateKey == "" { + return walletUpdatedMsg{success: false, err: fmt.Errorf("private key cannot be empty")} + } + + // Validate private key format + err := m.walletService.ValidatePrivateKey(newPrivateKey) + if err != nil { + return walletUpdatedMsg{success: false, err: fmt.Errorf("invalid private key: %w", err)} + } + + // Save old address + oldAddress := m.wallet.Wallet.Address + + // Update private key + err = m.walletService.UpdateWalletPrivateKey(m.walletID, newPrivateKey) + if err != nil { + return walletUpdatedMsg{success: false, err: err} + } + + // Reload wallet to get new address and balance + rpcEndpoint := "http://localhost:8545" + updatedWallet, err := m.walletService.GetWalletWithBalance(m.walletID, rpcEndpoint) + if err != nil { + logger.Warn("Failed to reload wallet after update: %v", err) + return walletUpdatedMsg{ + success: true, + updateType: "private_key", + oldAddress: oldAddress, + } + } + + return walletUpdatedMsg{ + success: true, + updateType: "private_key", + oldAddress: oldAddress, + newAddress: updatedWallet.Wallet.Address, + newBalance: updatedWallet.Balance, + } +} + +//nolint:gocognit,gocyclo // Update method complexity is acceptable for state machine +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case walletLoadedMsg: + if msg.err != nil { + m.loading = false + m.errorMsg = msg.err.Error() + return m, nil + } + + m.loading = false + m.walletID = msg.walletID + m.wallet = msg.wallet + m.walletService = msg.walletService + + // Set initial alias value + m.aliasInput.SetValue(m.wallet.Wallet.Alias) + + return m, nil + + case walletUpdatedMsg: + if msg.err != nil { + m.errorMsg = msg.err.Error() + return m, nil + } + + if msg.success { + logger.Info("Wallet updated successfully: %s", msg.updateType) + m.currentStep = stepSuccess + m.oldAddress = msg.oldAddress + m.newAddress = msg.newAddress + + if msg.newBalance != nil { + ethValue := new(big.Float).Quo( + new(big.Float).SetInt(msg.newBalance), + new(big.Float).SetInt(big.NewInt(1e18)), + ) + m.updatedBalance = fmt.Sprintf("%.4f ETH", ethValue) + } + } + + return m, nil + + case tea.KeyMsg: + if m.loading { + return m, nil + } + + switch m.currentStep { + case stepSelectUpdate: + switch msg.String() { + case "up", "k": + if m.selectedIndex > 0 { + m.selectedIndex-- + } + + case "down", "j": + if m.selectedIndex < len(m.options)-1 { + m.selectedIndex++ + } + + case "enter": + option := m.options[m.selectedIndex] + if option.step == -1 { + // Cancel - go back + return m, func() tea.Msg { + _ = m.router.NavigateTo("/evm/wallet", nil) + return nil + } + } + m.currentStep = option.step + m.selectedIndex = 0 + + // Focus appropriate input + if option.step == stepUpdateAlias { + m.aliasInput.Focus() + return m, textinput.Blink + } + } + + case stepUpdateAlias: + m.aliasInput, cmd = m.aliasInput.Update(msg) + + switch msg.String() { + case "enter": + return m, m.updateAlias + case "esc": + m.currentStep = stepSelectUpdate + m.errorMsg = "" + } + return m, cmd + + case stepPrivateKeyWarning: + switch msg.String() { + case "up", "k": + if m.selectedIndex > 0 { + m.selectedIndex-- + } + + case "down", "j": + if m.selectedIndex < len(m.confirmOpts)-1 { + m.selectedIndex++ + } + + case "enter": + option := m.confirmOpts[m.selectedIndex] + if option.value { + // User confirmed, proceed to enter new private key + m.currentStep = stepUpdatePrivateKey + m.pkeyInput.Focus() + return m, textinput.Blink + } else { + // User cancelled, go back to selection + m.currentStep = stepSelectUpdate + m.selectedIndex = 0 + } + + case "esc": + m.currentStep = stepSelectUpdate + m.selectedIndex = 0 + } + + case stepUpdatePrivateKey: + m.pkeyInput, cmd = m.pkeyInput.Update(msg) + + switch msg.String() { + case "enter": + return m, m.updatePrivateKey + case "esc": + m.currentStep = stepPrivateKeyWarning + m.selectedIndex = 0 + m.errorMsg = "" + } + return m, cmd + + case stepSuccess: + // Any key goes back to wallet list + return m, func() tea.Msg { + _ = m.router.NavigateTo("/evm/wallet", nil) + return nil + } + } + } + + return m, nil +} + +func (m Model) Help() (string, view.HelpDisplayOption) { + if m.loading { + return "Loading...", view.HelpDisplayOptionOverride + } + + switch m.currentStep { + case stepUpdateAlias, stepUpdatePrivateKey: + return "enter: save • esc: cancel", view.HelpDisplayOptionOverride + case stepSuccess: + return "Press any key to return to wallet list...", view.HelpDisplayOptionOverride + default: + return "↑/k: up • ↓/j: down • enter: select • esc: cancel", view.HelpDisplayOptionAppend + } +} + +func (m Model) View() string { + if m.loading { + return component.VStackC( + component.T("Update Wallet").Bold(true).Primary(), + component.SpacerV(1), + component.T("Loading wallet...").Muted(), + ).Render() + } + + if m.errorMsg != "" && m.currentStep != stepUpdateAlias && m.currentStep != stepUpdatePrivateKey { + return component.VStackC( + component.T("Update Wallet").Bold(true).Primary(), + component.SpacerV(1), + component.T("Error: "+m.errorMsg).Error(), + component.SpacerV(1), + component.T("Press 'esc' to go back").Muted(), + ).Render() + } + + switch m.currentStep { + case stepSelectUpdate: + return m.renderSelectUpdate() + case stepUpdateAlias: + return m.renderUpdateAlias() + case stepPrivateKeyWarning: + return m.renderPrivateKeyWarning() + case stepUpdatePrivateKey: + return m.renderUpdatePrivateKey() + case stepSuccess: + return m.renderSuccess() + default: + return "" + } +} + +func (m Model) renderSelectUpdate() string { + // Build options + optionComponents := make([]component.Component, 0) + for i, option := range m.options { + isCursor := i == m.selectedIndex + + prefix := " " + if isCursor { + prefix = "> " + } + + labelStyle := component.T(prefix + option.label) + if isCursor { + labelStyle = labelStyle.Bold(true) + } + + optionComponents = append(optionComponents, component.VStackC( + labelStyle, + component.T(" "+option.description).Muted(), + component.SpacerV(1), + )) + } + + return component.VStackC( + component.T("Update Wallet - "+m.wallet.Wallet.Alias).Bold(true).Primary(), + component.SpacerV(1), + component.T("Current Information:").Bold(true), + component.T("• Alias: "+m.wallet.Wallet.Alias).Muted(), + component.T("• Address: "+m.wallet.Wallet.Address).Muted(), + component.SpacerV(1), + component.T("What would you like to update?").Bold(true), + component.SpacerV(1), + component.VStackC(optionComponents...), + ).Render() +} + +func (m Model) renderUpdateAlias() string { + errorDisplay := component.Empty() + if m.errorMsg != "" { + errorDisplay = component.VStackC( + component.SpacerV(1), + component.T("Error: "+m.errorMsg).Error(), + ) + } + + return component.VStackC( + component.T("Update Wallet - Update Alias").Bold(true).Primary(), + component.SpacerV(1), + component.T("Current alias: "+m.wallet.Wallet.Alias).Muted(), + component.SpacerV(1), + component.T("Enter new alias:"), + component.SpacerV(1), + component.T("New Alias: "+m.aliasInput.View()), + errorDisplay, + ).Render() +} + +func (m Model) renderPrivateKeyWarning() string { + // Build options + optionComponents := make([]component.Component, 0) + for i, option := range m.confirmOpts { + isCursor := i == m.selectedIndex + + prefix := " " + if isCursor { + prefix = "> " + } + + labelStyle := component.T(prefix + option.label) + if isCursor { + labelStyle = labelStyle.Bold(true) + } + + var description string + if option.value { + description = "I understand the consequences" + } else { + description = "Keep the current private key" + } + + optionComponents = append(optionComponents, component.VStackC( + labelStyle, + component.T(" "+description).Muted(), + component.SpacerV(1), + )) + } + + return component.VStackC( + component.T("Update Wallet - Update Private Key").Bold(true).Primary(), + component.SpacerV(1), + component.T("⚠ Warning: Changing Private Key").Warning(), + component.SpacerV(1), + component.T("Updating the private key will:"), + component.T("• Change the wallet address").Muted(), + component.T("• This wallet will control a different Ethereum account").Muted(), + component.T("• You will lose access to the old address funds").Muted(), + component.SpacerV(1), + component.T("Current Address: "+m.wallet.Wallet.Address).Muted(), + component.SpacerV(1), + component.T("Are you sure you want to continue?").Bold(true), + component.SpacerV(1), + component.VStackC(optionComponents...), + ).Render() +} + +func (m Model) renderUpdatePrivateKey() string { + errorDisplay := component.Empty() + if m.errorMsg != "" { + errorDisplay = component.VStackC( + component.SpacerV(1), + component.T("Error: "+m.errorMsg).Error(), + ) + } + + return component.VStackC( + component.T("Update Wallet - Update Private Key").Bold(true).Primary(), + component.SpacerV(1), + component.T("Alias: "+m.wallet.Wallet.Alias).Muted(), + component.SpacerV(1), + component.T("Enter new private key (hex format, with or without 0x prefix):"), + component.SpacerV(1), + component.T("Private Key: "+m.pkeyInput.View()), + component.SpacerV(1), + component.T("⚠ This will replace the existing private key and change the address!").Warning(), + errorDisplay, + ).Render() +} + +func (m Model) renderSuccess() string { + if m.oldAddress != "" && m.newAddress != "" { + // Private key was updated + balanceStr := "0.0000 ETH" + if m.updatedBalance != "" { + balanceStr = m.updatedBalance + } + + return component.VStackC( + component.T("Update Wallet - Success").Bold(true).Primary(), + component.SpacerV(1), + component.T("✓ Wallet updated successfully!").Success(), + component.SpacerV(1), + component.T("Changes:").Bold(true), + component.T("• Alias: "+m.wallet.Wallet.Alias+" (unchanged)").Muted(), + component.T("• Old Address: "+m.oldAddress).Muted(), + component.T("• New Address: "+m.newAddress).Muted(), + component.T("• New Balance: "+balanceStr+" (on http://localhost:8545)").Muted(), + component.SpacerV(1), + component.T("⚠ Important: The old address is no longer accessible with this wallet!").Warning(), + ).Render() + } else { + // Alias was updated + return component.VStackC( + component.T("Update Wallet - Success").Bold(true).Primary(), + component.SpacerV(1), + component.T("✓ Wallet alias updated successfully!").Success(), + component.SpacerV(1), + component.T("New alias: "+m.aliasInput.Value()).Bold(true), + ).Render() + } +} diff --git a/app/page.go b/app/page.go index 64dae04..3152d99 100644 --- a/app/page.go +++ b/app/page.go @@ -13,6 +13,7 @@ import ( "github.com/rxtech-lab/smart-contract-cli/internal/log" "github.com/rxtech-lab/smart-contract-cli/internal/storage" "github.com/rxtech-lab/smart-contract-cli/internal/ui/component" + "github.com/rxtech-lab/smart-contract-cli/internal/utils" "github.com/rxtech-lab/smart-contract-cli/internal/view" ) @@ -175,6 +176,12 @@ func (m Model) handlePasswordSubmit(password string) (Model, tea.Cmd) { return m, nil } + if err := m.createConfig(); err != nil { + logger.Error("Failed to create config: %v", err) + m.errorMessage = fmt.Sprintf("Failed to create config: %v", err) + return m, nil + } + m.isUnlocked = true m.errorMessage = "" return m, nil @@ -270,6 +277,30 @@ func (m Model) unlockAndStorePassword(password string) error { return nil } +func (m Model) createConfig() error { + // Check if storage client exists in shared memory + storageClient, err := m.sharedMemory.Get(config.StorageClientKey) + if err != nil { + return fmt.Errorf("failed to get storage client from shared memory: %w", err) + } + + // If storage client is nil, it means it hasn't been configured yet + // This is valid for first-time users, so we don't treat it as an error + if storageClient == nil { + logger.Info("Storage client not configured yet, skipping config creation") + return nil + } + + sqlStorage, err := utils.GetStorageClientFromSharedMemory(m.sharedMemory) + if err != nil { + return fmt.Errorf("failed to get storage client from shared memory: %w", err) + } + if err := sqlStorage.CreateConfig(); err != nil { + return fmt.Errorf("failed to create config: %w", err) + } + return nil +} + func (m Model) moveUp(currentIndex int) int { if currentIndex > 0 { return currentIndex - 1 diff --git a/go.mod b/go.mod index 87b5458..e7e5c99 100644 --- a/go.mod +++ b/go.mod @@ -57,6 +57,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect diff --git a/go.sum b/go.sum index c74fef4..ae9b994 100644 --- a/go.sum +++ b/go.sum @@ -201,6 +201,8 @@ github.com/rxtech-lab/solc-go v0.1.2 h1:4pmsj6Cx7Lh5k9wo81dHLhpnpyRUxkzNSSbzAbbM github.com/rxtech-lab/solc-go v0.1.2/go.mod h1:fQm7D2u4dTIiz+/FSGFJVZx6uMeu0m/C6ga1rytbonI= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw= diff --git a/internal/contract/evm/storage/models/evm/evm_config.go b/internal/contract/evm/storage/models/evm/evm_config.go index 9eb2cd7..c12ea1e 100644 --- a/internal/contract/evm/storage/models/evm/evm_config.go +++ b/internal/contract/evm/storage/models/evm/evm_config.go @@ -10,6 +10,8 @@ type EVMConfig struct { SelectedEVMContract *EVMContract `json:"selected_evm_contract,omitempty" gorm:"foreignKey:SelectedEVMContractId;references:ID"` SelectedEVMAbiId *uint `json:"selected_evm_abi_id" gorm:"index;constraint:OnDelete:SET NULL"` SelectedEVMAbi *EvmAbi `json:"selected_evm_abi,omitempty" gorm:"foreignKey:SelectedEVMAbiId;references:ID"` + SelectedWalletID *uint `json:"selected_wallet_id" gorm:"index;constraint:OnDelete:SET NULL"` + SelectedWallet *EVMWallet `json:"selected_wallet,omitempty" gorm:"foreignKey:SelectedWalletID;references:ID"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } diff --git a/internal/contract/evm/storage/sql/queries/config_queries.go b/internal/contract/evm/storage/sql/queries/config_queries.go index 327e621..ebfa975 100644 --- a/internal/contract/evm/storage/sql/queries/config_queries.go +++ b/internal/contract/evm/storage/sql/queries/config_queries.go @@ -4,7 +4,6 @@ import ( "errors" models "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/models/evm" - "github.com/rxtech-lab/smart-contract-cli/internal/contract/types" customerrors "github.com/rxtech-lab/smart-contract-cli/internal/errors" "gorm.io/gorm" ) @@ -19,44 +18,19 @@ func NewConfigQueries(db *gorm.DB) *ConfigQueries { return &ConfigQueries{db: db} } -// List retrieves a paginated list of configs with preloaded relationships. -func (q *ConfigQueries) List(page int64, pageSize int64) (*types.Pagination[models.EVMConfig], error) { - if page < 1 { - return nil, customerrors.NewDatabaseError(customerrors.ErrCodeInvalidPageNumber, "page number must be greater than 0") - } - if pageSize < 1 { - return nil, customerrors.NewDatabaseError(customerrors.ErrCodeInvalidPageSize, "page size must be greater than 0") - } - - var items []models.EVMConfig - var totalItems int64 - - // Count total items - if err := q.db.Model(&models.EVMConfig{}).Count(&totalItems).Error; err != nil { - return nil, customerrors.WrapDatabaseError(err, customerrors.ErrCodeDatabaseOperationFailed, "failed to count configs") - } - - // Calculate total pages - totalPages := (totalItems + pageSize - 1) / pageSize - - // Retrieve paginated items with preloaded relationships - offset := (page - 1) * pageSize +func (q *ConfigQueries) GetCurrent() (*models.EVMConfig, error) { + var config models.EVMConfig if err := q.db.Preload("Endpoint"). Preload("SelectedEVMContract"). Preload("SelectedEVMAbi"). - Offset(int(offset)).Limit(int(pageSize)). - Order("created_at DESC"). - Find(&items).Error; err != nil { - return nil, customerrors.WrapDatabaseError(err, customerrors.ErrCodeDatabaseOperationFailed, "failed to list configs") + Preload("SelectedWallet"). + First(&config).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, customerrors.WrapDatabaseError(err, customerrors.ErrCodeRecordNotFound, "no config found") + } + return nil, customerrors.WrapDatabaseError(err, customerrors.ErrCodeDatabaseOperationFailed, "failed to get current config") } - - return &types.Pagination[models.EVMConfig]{ - Items: items, - TotalPages: totalPages, - CurrentPage: page, - PageSize: pageSize, - TotalItems: totalItems, - }, nil + return &config, nil } // GetByID retrieves a config by its ID with preloaded relationships. @@ -74,17 +48,47 @@ func (q *ConfigQueries) GetByID(id uint) (*models.EVMConfig, error) { return &config, nil } -// Create creates a new config. -func (q *ConfigQueries) Create(config *models.EVMConfig) error { +// Create creates a new empty config if one doesn't exist, or skips if it does. +func (q *ConfigQueries) Create() error { + // Check if config already exists + var count int64 + if err := q.db.Model(&models.EVMConfig{}).Count(&count).Error; err != nil { + return customerrors.WrapDatabaseError(err, customerrors.ErrCodeDatabaseOperationFailed, "failed to check config existence") + } + + // If config exists, skip creation + if count > 0 { + return nil + } + + // Create empty config + config := &models.EVMConfig{} if err := q.db.Create(config).Error; err != nil { return customerrors.WrapDatabaseError(err, customerrors.ErrCodeDatabaseOperationFailed, "failed to create config") } return nil } -// Update updates a config by ID with the provided updates. -func (q *ConfigQueries) Update(id uint, updates map[string]interface{}) error { - result := q.db.Model(&models.EVMConfig{}).Where("id = ?", id).Updates(updates) +// Update updates the current config with the provided config object. +func (q *ConfigQueries) Update(config *models.EVMConfig) error { + // Get current config first + currentConfig, err := q.GetCurrent() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return customerrors.NewDatabaseError(customerrors.ErrCodeRecordNotFound, "no config found to update") + } + return customerrors.WrapDatabaseError(err, customerrors.ErrCodeDatabaseOperationFailed, "failed to get current config") + } + + // Update current config with provided values + updates := map[string]any{ + "endpoint_id": config.EndpointId, + "selected_evm_contract_id": config.SelectedEVMContractId, + "selected_evm_abi_id": config.SelectedEVMAbiId, + "selected_wallet_id": config.SelectedWalletID, + } + + result := q.db.Model(&models.EVMConfig{}).Where("id = ?", currentConfig.ID).Updates(updates) if result.Error != nil { return customerrors.WrapDatabaseError(result.Error, customerrors.ErrCodeDatabaseOperationFailed, "failed to update config") } @@ -94,9 +98,18 @@ func (q *ConfigQueries) Update(id uint, updates map[string]interface{}) error { return nil } -// Delete deletes a config by ID. -func (q *ConfigQueries) Delete(id uint) error { - result := q.db.Delete(&models.EVMConfig{}, id) +// Delete deletes the current config. +func (q *ConfigQueries) Delete() error { + // Get current config first + currentConfig, err := q.GetCurrent() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return customerrors.NewDatabaseError(customerrors.ErrCodeRecordNotFound, "no config found to delete") + } + return customerrors.WrapDatabaseError(err, customerrors.ErrCodeDatabaseOperationFailed, "failed to get current config") + } + + result := q.db.Delete(&models.EVMConfig{}, currentConfig.ID) if result.Error != nil { return customerrors.WrapDatabaseError(result.Error, customerrors.ErrCodeDatabaseOperationFailed, "failed to delete config") } @@ -114,47 +127,3 @@ func (q *ConfigQueries) Exists(id uint) (bool, error) { } return count > 0, nil } - -// Count returns the total number of configs. -func (q *ConfigQueries) Count() (int64, error) { - var count int64 - if err := q.db.Model(&models.EVMConfig{}).Count(&count).Error; err != nil { - return 0, customerrors.WrapDatabaseError(err, customerrors.ErrCodeDatabaseOperationFailed, "failed to count configs") - } - return count, nil -} - -// Search searches for configs by related endpoint name. -func (q *ConfigQueries) Search(query string) (*types.Pagination[models.EVMConfig], error) { - var items []models.EVMConfig - var totalItems int64 - - searchPattern := "%" + query + "%" - - // Count total matching items - if err := q.db.Model(&models.EVMConfig{}). - Joins("LEFT JOIN evm_endpoints ON evm_configs.endpoint_id = evm_endpoints.id"). - Where("evm_endpoints.name LIKE ?", searchPattern). - Count(&totalItems).Error; err != nil { - return nil, customerrors.WrapDatabaseError(err, customerrors.ErrCodeDatabaseOperationFailed, "failed to count configs") - } - - // Retrieve all matching items with preloaded relationships - if err := q.db.Preload("Endpoint"). - Preload("SelectedEVMContract"). - Preload("SelectedEVMAbi"). - Joins("LEFT JOIN evm_endpoints ON evm_configs.endpoint_id = evm_endpoints.id"). - Where("evm_endpoints.name LIKE ?", searchPattern). - Order("evm_configs.created_at DESC"). - Find(&items).Error; err != nil { - return nil, customerrors.WrapDatabaseError(err, customerrors.ErrCodeDatabaseOperationFailed, "failed to search configs") - } - - return &types.Pagination[models.EVMConfig]{ - Items: items, - TotalPages: 1, - CurrentPage: 1, - PageSize: totalItems, - TotalItems: totalItems, - }, nil -} diff --git a/internal/contract/evm/storage/sql/sqlite.go b/internal/contract/evm/storage/sql/sqlite.go index 21d129a..28699e7 100644 --- a/internal/contract/evm/storage/sql/sqlite.go +++ b/internal/contract/evm/storage/sql/sqlite.go @@ -225,27 +225,17 @@ func (s *SQLiteStorage) UpdateContract(contractID uint, contract models.EVMContr } // Config Methods - -// CountConfigs implements Storage. -func (s *SQLiteStorage) CountConfigs() (count int64, err error) { - count, err = s.configQueries.Count() - if err != nil { - return 0, fmt.Errorf("failed to count configs: %w", err) - } - return count, nil -} - // CreateConfig implements Storage. -func (s *SQLiteStorage) CreateConfig(config models.EVMConfig) (id uint, err error) { - if err := s.configQueries.Create(&config); err != nil { - return 0, fmt.Errorf("failed to create config: %w", err) +func (s *SQLiteStorage) CreateConfig() (err error) { + if err := s.configQueries.Create(); err != nil { + return fmt.Errorf("failed to create config: %w", err) } - return config.ID, nil + return nil } // DeleteConfig implements Storage. -func (s *SQLiteStorage) DeleteConfig(id uint) (err error) { - if err := s.configQueries.Delete(id); err != nil { +func (s *SQLiteStorage) DeleteConfig() (err error) { + if err := s.configQueries.Delete(); err != nil { return fmt.Errorf("failed to delete config: %w", err) } return nil @@ -260,32 +250,9 @@ func (s *SQLiteStorage) GetConfigByID(id uint) (config models.EVMConfig, err err return *result, nil } -// ListConfigs implements Storage. -func (s *SQLiteStorage) ListConfigs(page int64, pageSize int64) (configs types.Pagination[models.EVMConfig], err error) { - result, err := s.configQueries.List(page, pageSize) - if err != nil { - return types.Pagination[models.EVMConfig]{}, fmt.Errorf("failed to list configs: %w", err) - } - return *result, nil -} - -// SearchConfigs implements Storage. -func (s *SQLiteStorage) SearchConfigs(query string) (configs types.Pagination[models.EVMConfig], err error) { - result, err := s.configQueries.Search(query) - if err != nil { - return types.Pagination[models.EVMConfig]{}, fmt.Errorf("failed to search configs: %w", err) - } - return *result, nil -} - // UpdateConfig implements Storage. -func (s *SQLiteStorage) UpdateConfig(configID uint, config models.EVMConfig) (err error) { - updates := map[string]any{ - "endpoint_id": config.EndpointId, - "selected_evm_contract_id": config.SelectedEVMContractId, - "selected_evm_abi_id": config.SelectedEVMAbiId, - } - if err := s.configQueries.Update(configID, updates); err != nil { +func (s *SQLiteStorage) UpdateConfig(config models.EVMConfig) (err error) { + if err := s.configQueries.Update(&config); err != nil { return fmt.Errorf("failed to update config: %w", err) } return nil @@ -395,6 +362,15 @@ func (s *SQLiteStorage) WalletExistsByAlias(alias string) (exists bool, err erro return exists, nil } +// GetCurrentConfig implements Storage. +func (s *SQLiteStorage) GetCurrentConfig() (config models.EVMConfig, err error) { + result, err := s.configQueries.GetCurrent() + if err != nil { + return models.EVMConfig{}, fmt.Errorf("failed to get current config: %w", err) + } + return *result, nil +} + // NewSQLiteDB creates a new SQLite database connection. // If dbPath is empty, it defaults to $HOME/smart-contract-cli.db. func NewSQLiteDB(dbPath string) (Storage, error) { diff --git a/internal/contract/evm/storage/sql/storage.go b/internal/contract/evm/storage/sql/storage.go index d9788e7..a1b70f9 100644 --- a/internal/contract/evm/storage/sql/storage.go +++ b/internal/contract/evm/storage/sql/storage.go @@ -36,13 +36,11 @@ type Storage interface { DeleteContract(id uint) (err error) // Config methods - CreateConfig(config models.EVMConfig) (id uint, err error) - ListConfigs(page int64, pageSize int64) (configs types.Pagination[models.EVMConfig], err error) - SearchConfigs(query string) (configs types.Pagination[models.EVMConfig], err error) + CreateConfig() (err error) + GetCurrentConfig() (config models.EVMConfig, err error) GetConfigByID(id uint) (config models.EVMConfig, err error) - CountConfigs() (count int64, err error) - UpdateConfig(id uint, config models.EVMConfig) (err error) - DeleteConfig(id uint) (err error) + UpdateConfig(config models.EVMConfig) (err error) + DeleteConfig() (err error) // Wallet methods CreateWallet(wallet models.EVMWallet) (id uint, err error) diff --git a/internal/utils/storage.go b/internal/utils/storage.go index 07b659d..ab8f39b 100644 --- a/internal/utils/storage.go +++ b/internal/utils/storage.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/rxtech-lab/smart-contract-cli/internal/config" + "github.com/rxtech-lab/smart-contract-cli/internal/contract/evm/storage/sql" "github.com/rxtech-lab/smart-contract-cli/internal/storage" ) @@ -30,3 +31,16 @@ func GetSecureStorageFromSharedMemory(sharedMemory storage.SharedMemory) (storag return secureStorage, password, nil } + +// GetStorageClientFromSharedMemory gets the storage client from the shared memory. +func GetStorageClientFromSharedMemory(sharedMemory storage.SharedMemory) (sql.Storage, error) { + storageClient, err := sharedMemory.Get(config.StorageClientKey) + if err != nil { + return nil, fmt.Errorf("failed to get storage client from shared memory: %w", err) + } + sqlStorage, isValidStorage := storageClient.(sql.Storage) + if !isValidStorage { + return nil, fmt.Errorf("invalid storage client type") + } + return sqlStorage, nil +} diff --git a/internal/view/router.go b/internal/view/router.go index 4619920..18e666d 100644 --- a/internal/view/router.go +++ b/internal/view/router.go @@ -58,6 +58,12 @@ func (r *RouterImplementation) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // handle esc key if msg, ok := msg.(tea.KeyMsg); ok && msg.String() == "esc" { r.Back() + // After going back, check if there's a pending command from the new page + if r.pendingCmd != nil { + pendingCmd := r.pendingCmd + r.pendingCmd = nil + return r, pendingCmd + } return r, nil } @@ -65,20 +71,15 @@ func (r *RouterImplementation) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return r, nil } - // Track if navigation occurred during update - navigationOccurred := false - // Store a reference to check if navigation changed it oldStackSize := len(r.navigationStack) updatedModel, cmd := r.currentComponent.Update(msg) // Check if navigation occurred (stack size changed or pendingCmd set) - if len(r.navigationStack) != oldStackSize || r.pendingCmd != nil { - navigationOccurred = true - } + navigationOccurred := len(r.navigationStack) != oldStackSize || r.pendingCmd != nil - // If pending command exists, return it immediately + // If pending command exists (from navigation), return it immediately if r.pendingCmd != nil { pendingCmd := r.pendingCmd r.pendingCmd = nil diff --git a/internal/view/router_test.go b/internal/view/router_test.go index 4c57096..dea5025 100644 --- a/internal/view/router_test.go +++ b/internal/view/router_test.go @@ -9,27 +9,27 @@ import ( "github.com/stretchr/testify/suite" ) -// MockView is a mock implementation of View for testing. -type MockView struct { +// SimpleView is a mock implementation of View for testing. +type SimpleView struct { name string initCalled bool viewContent string } -func (m *MockView) Init() tea.Cmd { +func (m *SimpleView) Init() tea.Cmd { m.initCalled = true return nil } -func (m *MockView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *SimpleView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -func (m *MockView) View() string { +func (m *SimpleView) View() string { return m.viewContent } -func (m *MockView) Help() (string, HelpDisplayOption) { +func (m *SimpleView) Help() (string, HelpDisplayOption) { return "", HelpDisplayOptionAppend } @@ -53,7 +53,7 @@ func (suite *RouterTestSuite) TestNewRouter() { // TestAddRoute tests adding routes. func (suite *RouterTestSuite) TestAddRoute() { - mockView := &MockView{name: "home", viewContent: "Home View"} + mockView := &SimpleView{name: "home", viewContent: "Home View"} route := Route{ Path: "/", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView }, @@ -68,8 +68,8 @@ func (suite *RouterTestSuite) TestAddRoute() { // TestSetRoutes tests setting multiple routes at once. func (suite *RouterTestSuite) TestSetRoutes() { - mockView1 := &MockView{name: "home", viewContent: "Home View"} - mockView2 := &MockView{name: "about", viewContent: "About View"} + mockView1 := &SimpleView{name: "home", viewContent: "Home View"} + mockView2 := &SimpleView{name: "about", viewContent: "About View"} routes := []Route{ {Path: "/", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView1 }}, @@ -86,8 +86,8 @@ func (suite *RouterTestSuite) TestSetRoutes() { // TestRemoveRoute tests removing a route. func (suite *RouterTestSuite) TestRemoveRoute() { - mockView1 := &MockView{name: "home", viewContent: "Home View"} - mockView2 := &MockView{name: "about", viewContent: "About View"} + mockView1 := &SimpleView{name: "home", viewContent: "Home View"} + mockView2 := &SimpleView{name: "about", viewContent: "About View"} suite.router.AddRoute(Route{Path: "/", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView1 }}) suite.router.AddRoute(Route{Path: "/about", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView2 }}) @@ -101,7 +101,7 @@ func (suite *RouterTestSuite) TestRemoveRoute() { // TestNavigateTo tests navigation to a route. func (suite *RouterTestSuite) TestNavigateTo() { - mockView := &MockView{name: "home", viewContent: "Home View"} + mockView := &SimpleView{name: "home", viewContent: "Home View"} suite.router.AddRoute(Route{Path: "/", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView }}) err := suite.router.NavigateTo("/", nil) @@ -114,7 +114,7 @@ func (suite *RouterTestSuite) TestNavigateTo() { // TestNavigateToWithQueryParams tests navigation with query parameters. func (suite *RouterTestSuite) TestNavigateToWithQueryParams() { - mockView := &MockView{name: "users", viewContent: "Users View"} + mockView := &SimpleView{name: "users", viewContent: "Users View"} suite.router.AddRoute(Route{Path: "/users", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView }}) queryParams := map[string]string{ @@ -138,7 +138,7 @@ func (suite *RouterTestSuite) TestNavigateToInvalidRoute() { // TestNavigateToWithPathParams tests navigation with path parameters. func (suite *RouterTestSuite) TestNavigateToWithPathParams() { - mockView := &MockView{name: "user", viewContent: "User View"} + mockView := &SimpleView{name: "user", viewContent: "User View"} suite.router.AddRoute(Route{Path: "/users/:id", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView }}) err := suite.router.NavigateTo("/users/123", nil) @@ -149,7 +149,7 @@ func (suite *RouterTestSuite) TestNavigateToWithPathParams() { // TestNavigateToWithMultiplePathParams tests navigation with multiple path parameters. func (suite *RouterTestSuite) TestNavigateToWithMultiplePathParams() { - mockView := &MockView{name: "comment", viewContent: "Comment View"} + mockView := &SimpleView{name: "comment", viewContent: "Comment View"} suite.router.AddRoute(Route{Path: "/posts/:postId/comments/:commentId", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView }}) err := suite.router.NavigateTo("/posts/456/comments/789", nil) @@ -160,8 +160,8 @@ func (suite *RouterTestSuite) TestNavigateToWithMultiplePathParams() { // TestReplaceRoute tests replacing the current route. func (suite *RouterTestSuite) TestReplaceRoute() { - mockView1 := &MockView{name: "home", viewContent: "Home View"} - mockView2 := &MockView{name: "about", viewContent: "About View"} + mockView1 := &SimpleView{name: "home", viewContent: "Home View"} + mockView2 := &SimpleView{name: "about", viewContent: "About View"} suite.router.AddRoute(Route{Path: "/", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView1 }}) suite.router.AddRoute(Route{Path: "/about", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView2 }}) @@ -180,9 +180,9 @@ func (suite *RouterTestSuite) TestReplaceRoute() { // TestBackNavigation tests navigating back. func (suite *RouterTestSuite) TestBackNavigation() { - mockView1 := &MockView{name: "home", viewContent: "Home View"} - mockView2 := &MockView{name: "about", viewContent: "About View"} - mockView3 := &MockView{name: "contact", viewContent: "Contact View"} + mockView1 := &SimpleView{name: "home", viewContent: "Home View"} + mockView2 := &SimpleView{name: "about", viewContent: "About View"} + mockView3 := &SimpleView{name: "contact", viewContent: "Contact View"} suite.router.AddRoute(Route{Path: "/", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView1 }}) suite.router.AddRoute(Route{Path: "/about", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView2 }}) @@ -212,7 +212,7 @@ func (suite *RouterTestSuite) TestBackNavigation() { // TestBackWithEmptyStack tests back navigation with empty stack. func (suite *RouterTestSuite) TestBackWithEmptyStack() { - mockView := &MockView{name: "home", viewContent: "Home View"} + mockView := &SimpleView{name: "home", viewContent: "Home View"} suite.router.AddRoute(Route{Path: "/", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView }}) err := suite.router.NavigateTo("/", nil) @@ -226,8 +226,8 @@ func (suite *RouterTestSuite) TestBackWithEmptyStack() { // TestCanGoBack tests the CanGoBack method. func (suite *RouterTestSuite) TestCanGoBack() { - mockView1 := &MockView{name: "home", viewContent: "Home View"} - mockView2 := &MockView{name: "about", viewContent: "About View"} + mockView1 := &SimpleView{name: "home", viewContent: "Home View"} + mockView2 := &SimpleView{name: "about", viewContent: "About View"} suite.router.AddRoute(Route{Path: "/", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView1 }}) suite.router.AddRoute(Route{Path: "/about", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView2 }}) @@ -245,7 +245,7 @@ func (suite *RouterTestSuite) TestCanGoBack() { // TestGetCurrentRoute tests getting the current route. func (suite *RouterTestSuite) TestGetCurrentRoute() { - mockView := &MockView{name: "home", viewContent: "Home View"} + mockView := &SimpleView{name: "home", viewContent: "Home View"} componentFunc := func(r Router, sharedMemory storage.SharedMemory) View { return mockView } route := Route{Path: "/", Component: componentFunc} @@ -267,7 +267,7 @@ func (suite *RouterTestSuite) TestGetCurrentRouteEmpty() { // TestGetQueryParamNotFound tests getting a non-existent query parameter. func (suite *RouterTestSuite) TestGetQueryParamNotFound() { - mockView := &MockView{name: "home", viewContent: "Home View"} + mockView := &SimpleView{name: "home", viewContent: "Home View"} suite.router.AddRoute(Route{Path: "/", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView }}) err := suite.router.NavigateTo("/", nil) assert.NoError(suite.T(), err) @@ -278,7 +278,7 @@ func (suite *RouterTestSuite) TestGetQueryParamNotFound() { // TestGetParamNotFound tests getting a non-existent path parameter. func (suite *RouterTestSuite) TestGetParamNotFound() { - mockView := &MockView{name: "home", viewContent: "Home View"} + mockView := &SimpleView{name: "home", viewContent: "Home View"} suite.router.AddRoute(Route{Path: "/", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView }}) err := suite.router.NavigateTo("/", nil) assert.NoError(suite.T(), err) @@ -295,7 +295,7 @@ func (suite *RouterTestSuite) TestGetPathEmpty() { // TestRefresh tests refreshing the current route. func (suite *RouterTestSuite) TestRefresh() { - mockView := &MockView{name: "home", viewContent: "Home View"} + mockView := &SimpleView{name: "home", viewContent: "Home View"} suite.router.AddRoute(Route{Path: "/", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView }}) err := suite.router.NavigateTo("/", nil) assert.NoError(suite.T(), err) @@ -308,7 +308,7 @@ func (suite *RouterTestSuite) TestRefresh() { // TestViewMethod tests the View method. func (suite *RouterTestSuite) TestViewMethod() { - mockView := &MockView{name: "home", viewContent: "Home View Content"} + mockView := &SimpleView{name: "home", viewContent: "Home View Content"} suite.router.AddRoute(Route{Path: "/", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView }}) err := suite.router.NavigateTo("/", nil) assert.NoError(suite.T(), err) @@ -327,7 +327,7 @@ func (suite *RouterTestSuite) TestViewMethodNoRoute() { // TestInitMethod tests the Init method. func (suite *RouterTestSuite) TestInitMethod() { - mockView := &MockView{name: "home", viewContent: "Home View"} + mockView := &SimpleView{name: "home", viewContent: "Home View"} suite.router.AddRoute(Route{Path: "/", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView }}) err := suite.router.NavigateTo("/", nil) assert.NoError(suite.T(), err) @@ -341,7 +341,7 @@ func (suite *RouterTestSuite) TestInitMethod() { // TestUpdateMethod tests the Update method. func (suite *RouterTestSuite) TestUpdateMethod() { - mockView := &MockView{name: "home", viewContent: "Home View"} + mockView := &SimpleView{name: "home", viewContent: "Home View"} suite.router.AddRoute(Route{Path: "/", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView }}) err := suite.router.NavigateTo("/", nil) assert.NoError(suite.T(), err) @@ -355,7 +355,7 @@ func (suite *RouterTestSuite) TestUpdateMethod() { // TestMatchPatternExactMatch tests exact route matching. func (suite *RouterTestSuite) TestMatchPatternExactMatch() { - mockView := &MockView{name: "home", viewContent: "Home View"} + mockView := &SimpleView{name: "home", viewContent: "Home View"} suite.router.AddRoute(Route{Path: "/exact", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView }}) err := suite.router.NavigateTo("/exact", nil) @@ -365,7 +365,7 @@ func (suite *RouterTestSuite) TestMatchPatternExactMatch() { // TestMatchPatternComplexParams tests complex parameterized routes. func (suite *RouterTestSuite) TestMatchPatternComplexParams() { - mockView := &MockView{name: "complex", viewContent: "Complex View"} + mockView := &SimpleView{name: "complex", viewContent: "Complex View"} suite.router.AddRoute(Route{Path: "/api/:version/users/:userId/posts/:postId", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView }}) err := suite.router.NavigateTo("/api/v1/users/100/posts/200", nil) @@ -377,9 +377,9 @@ func (suite *RouterTestSuite) TestMatchPatternComplexParams() { // TestNavigationStackIntegrity tests that navigation stack maintains integrity. func (suite *RouterTestSuite) TestNavigationStackIntegrity() { - mockView1 := &MockView{name: "view1", viewContent: "View 1"} - mockView2 := &MockView{name: "view2", viewContent: "View 2"} - mockView3 := &MockView{name: "view3", viewContent: "View 3"} + mockView1 := &SimpleView{name: "view1", viewContent: "View 1"} + mockView2 := &SimpleView{name: "view2", viewContent: "View 2"} + mockView3 := &SimpleView{name: "view3", viewContent: "View 3"} suite.router.AddRoute(Route{Path: "/view1", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView1 }}) suite.router.AddRoute(Route{Path: "/view2", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView2 }}) @@ -404,8 +404,8 @@ func (suite *RouterTestSuite) TestNavigationStackIntegrity() { // TestParameterPersistenceAcrossNavigation tests that parameters are preserved during navigation. func (suite *RouterTestSuite) TestParameterPersistenceAcrossNavigation() { - mockView1 := &MockView{name: "users", viewContent: "Users View"} - mockView2 := &MockView{name: "posts", viewContent: "Posts View"} + mockView1 := &SimpleView{name: "users", viewContent: "Users View"} + mockView2 := &SimpleView{name: "posts", viewContent: "Posts View"} suite.router.AddRoute(Route{Path: "/users/:id", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView1 }}) suite.router.AddRoute(Route{Path: "/posts/:postId", Component: func(r Router, sharedMemory storage.SharedMemory) View { return mockView2 }}) diff --git a/tools/tools.go b/tools/tools.go index 91e9cf1..186e647 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -1,6 +1,26 @@ package tools +// This file contains go:generate directives for code generation. +// Run `make generate` to execute all generators. + +// Generate routes from app folder structure (Next.js-style file-based routing) //go:generate go run ./routergen -dir ../app -module-root .. + +// Generate mocks for testing using mockgen +// Pattern: mockgen -source= -destination= -package= +// +// IMPORTANT: Always add new mockgen directives here when creating new interfaces. +// This ensures mocks are regenerated when running `make generate`. +// +// Example for adding a new mock: +// //go:generate go run go.uber.org/mock/mockgen -source=../internal/path/to/interface.go -destination=../internal/path/to/mock_interface.go -package=packagename + +// Core service mocks //go:generate go run go.uber.org/mock/mockgen -source=../internal/contract/evm/wallet/service.go -destination=../internal/contract/evm/wallet/mock_service.go -package=wallet + +// Storage mocks //go:generate go run go.uber.org/mock/mockgen -source=../internal/contract/evm/storage/sql/storage.go -destination=../internal/contract/evm/storage/sql/mock_storage.go -package=sql //go:generate go run go.uber.org/mock/mockgen -source=../internal/storage/secure.go -destination=../internal/storage/mock_secure.go -package=storage + +// View layer mocks +//go:generate go run go.uber.org/mock/mockgen -source=../internal/view/types.go -destination=../internal/view/mock_router.go -package=view diff --git a/wallet.test b/wallet.test deleted file mode 100755 index 11b2b6c..0000000 Binary files a/wallet.test and /dev/null differ diff --git a/~/smart_contract_cli b/~/smart_contract_cli deleted file mode 100644 index 15d2312..0000000 Binary files a/~/smart_contract_cli and /dev/null differ