diff --git a/internal/cmd/abi.go b/internal/cmd/abi.go index e5257e71..9d0a4697 100644 --- a/internal/cmd/abi.go +++ b/internal/cmd/abi.go @@ -14,11 +14,12 @@ import ( var abiFormat string -var abiCmd = &cobra.Command{ - Use: "abi ", - GroupID: "utility", - Short: "Decompile and display a Soroban contract ABI", - Long: `Parse a compiled Soroban WASM file and pretty-print the contract specification +func NewAbiCmd() *cobra.Command { + abiCmd := &cobra.Command{ + Use: "abi ", + GroupID: "utility", + Short: "Decompile and display a Soroban contract ABI", + Long: `Parse a compiled Soroban WASM file and pretty-print the contract specification (functions, structs, enums, unions, error enums, and events). The contract spec is read from the "contractspecv0" WASM custom section, which @@ -27,8 +28,11 @@ Soroban compilers embed automatically. Examples: erst abi ./target/wasm32-unknown-unknown/release/contract.wasm erst abi --format json ./contract.wasm`, - Args: cobra.ExactArgs(1), - RunE: abiExec, + Args: cobra.ExactArgs(1), + RunE: abiExec, + } + abiCmd.Flags().StringVar(&abiFormat, "format", "text", "Output format: text or json") + return abiCmd } func abiExec(cmd *cobra.Command, args []string) error { @@ -65,8 +69,3 @@ func abiExec(cmd *cobra.Command, args []string) error { return nil } - -func init() { - abiCmd.Flags().StringVar(&abiFormat, "format", "text", "Output format: text or json") - rootCmd.AddCommand(abiCmd) -} diff --git a/internal/cmd/auth_debug.go b/internal/cmd/auth_debug.go index 8a171926..2e0614db 100644 --- a/internal/cmd/auth_debug.go +++ b/internal/cmd/auth_debug.go @@ -20,74 +20,82 @@ var ( authJSONOutputFlag bool ) -var authDebugCmd = &cobra.Command{ - Use: "auth-debug ", - GroupID: "core", - Short: "Debug multi-signature and threshold-based authorization failures", - Long: `Analyze multi-signature authorization flows and identify which signatures or thresholds failed. +func NewAuthDebugCmd() *cobra.Command { + authDebugCmd := &cobra.Command{ + Use: "auth-debug ", + GroupID: "core", + Short: "Debug multi-signature and threshold-based authorization failures", + Long: `Analyze multi-signature authorization flows and identify which signatures or thresholds failed. Examples: erst auth-debug erst auth-debug --detailed erst auth-debug --json `, - Args: cobra.ExactArgs(1), - PreRunE: func(cmd *cobra.Command, args []string) error { - switch rpc.Network(authNetworkFlag) { - case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: - default: - return errors.WrapInvalidNetwork(authNetworkFlag) - } - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - txHash := args[0] - - opts := []rpc.ClientOption{ - rpc.WithNetwork(rpc.Network(authNetworkFlag)), - } - if authRPCURLFlag != "" { - opts = append(opts, rpc.WithHorizonURL(authRPCURLFlag)) - } - - client, err := rpc.NewClient(opts...) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to create client: %v", err)) - } + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + switch rpc.Network(authNetworkFlag) { + case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: + default: + return errors.WrapInvalidNetwork(authNetworkFlag) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + txHash := args[0] - logger.Logger.Info("Fetching transaction for auth analysis", "tx_hash", txHash) + opts := []rpc.ClientOption{ + rpc.WithNetwork(rpc.Network(authNetworkFlag)), + } + if authRPCURLFlag != "" { + opts = append(opts, rpc.WithHorizonURL(authRPCURLFlag)) + } - resp, err := client.GetTransaction(cmd.Context(), txHash) - if err != nil { - return errors.WrapRPCConnectionFailed(err) - } + client, err := rpc.NewClient(opts...) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to create client: %v", err)) + } - fmt.Printf("Transaction Envelope: %d bytes\n", len(resp.EnvelopeXdr)) + logger.Logger.Info("Fetching transaction for auth analysis", "tx_hash", txHash) - config := authtrace.AuthTraceConfig{ - TraceCustomContracts: true, - CaptureSigDetails: true, - MaxEventDepth: 1000, - } + resp, err := client.GetTransaction(cmd.Context(), txHash) + if err != nil { + return errors.WrapRPCConnectionFailed(err) + } - tracker := authtrace.NewTracker(config) - trace := tracker.GenerateTrace() - reporter := authtrace.NewDetailedReporter(trace) + fmt.Printf("Transaction Envelope: %d bytes\n", len(resp.EnvelopeXdr)) - if authJSONOutputFlag { - jsonStr, err := reporter.GenerateJSONString() - if err != nil { - return err + config := authtrace.AuthTraceConfig{ + TraceCustomContracts: true, + CaptureSigDetails: true, + MaxEventDepth: 1000, } - fmt.Println(jsonStr) - } else { - fmt.Println(reporter.GenerateReport()) - if authDetailedFlag { - printDetailedAnalysis(reporter) + + tracker := authtrace.NewTracker(config) + trace := tracker.GenerateTrace() + reporter := authtrace.NewDetailedReporter(trace) + + if authJSONOutputFlag { + jsonStr, err := reporter.GenerateJSONString() + if err != nil { + return err + } + fmt.Println(jsonStr) + } else { + fmt.Println(reporter.GenerateReport()) + if authDetailedFlag { + printDetailedAnalysis(reporter) + } } - } - return nil - }, + return nil + }, + } + authDebugCmd.Flags().StringVarP(&authNetworkFlag, "network", "n", string(rpc.Mainnet), "Stellar network (testnet, mainnet, futurenet)") + authDebugCmd.Flags().StringVar(&authRPCURLFlag, "rpc-url", "", "Custom Horizon RPC URL") + authDebugCmd.Flags().BoolVar(&authDetailedFlag, "detailed", false, "Show detailed analysis and missing signatures") + authDebugCmd.Flags().BoolVar(&authJSONOutputFlag, "json", false, "Output as JSON") + _ = authDebugCmd.RegisterFlagCompletionFunc("network", completeNetworkFlag) + return authDebugCmd } func printDetailedAnalysis(reporter *authtrace.DetailedReporter) { @@ -105,14 +113,3 @@ func printDetailedAnalysis(reporter *authtrace.DetailedReporter) { } } } - -func init() { - authDebugCmd.Flags().StringVarP(&authNetworkFlag, "network", "n", string(rpc.Mainnet), "Stellar network (testnet, mainnet, futurenet)") - authDebugCmd.Flags().StringVar(&authRPCURLFlag, "rpc-url", "", "Custom Horizon RPC URL") - authDebugCmd.Flags().BoolVar(&authDetailedFlag, "detailed", false, "Show detailed analysis and missing signatures") - authDebugCmd.Flags().BoolVar(&authJSONOutputFlag, "json", false, "Output as JSON") - - _ = authDebugCmd.RegisterFlagCompletionFunc("network", completeNetworkFlag) - - rootCmd.AddCommand(authDebugCmd) -} diff --git a/internal/cmd/cache.go b/internal/cmd/cache.go index 0ac189ae..a9b4e59e 100644 --- a/internal/cmd/cache.go +++ b/internal/cmd/cache.go @@ -31,11 +31,12 @@ func getCacheDir() string { return filepath.Join(homeDir, ".erst", "cache") } -var cacheCmd = &cobra.Command{ - Use: "cache", - GroupID: "management", - Short: "Manage transaction and simulation cache", - Long: `Manage the local cache that stores transaction data and simulation results. +func NewCacheCmd() *cobra.Command { + cacheCmd := &cobra.Command{ + Use: "cache", + GroupID: "management", + Short: "Manage transaction and simulation cache", + Long: `Manage the local cache that stores transaction data and simulation results. Caching improves performance and enables offline analysis. Cache location: ~/.erst/cache (configurable via ERST_CACHE_DIR) @@ -44,7 +45,7 @@ Available subcommands: status - View cache size and usage statistics clean - Remove old files using LRU strategy clear - Delete all cached data`, - Example: ` # Check cache status + Example: ` # Check cache status erst cache status # Clean old cache entries @@ -55,10 +56,24 @@ Available subcommands: # Clear all cache erst cache clear --force`, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - return cmd.Help() - }, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + // Add subcommands to cache command + cacheCmd.AddCommand(cacheStatusCmd) + cacheCmd.AddCommand(cacheCleanCmd) + cacheCmd.AddCommand(cacheClearCmd) + cacheCmd.AddCommand(cacheCleanRPCCmd) + // Add flags + cacheCleanCmd.Flags().BoolVarP(&cacheForceFlag, "force", "f", false, "Skip confirmation prompt") + cacheClearCmd.Flags().BoolVarP(&cacheForceFlag, "force", "f", false, "Skip confirmation prompt") + cacheCleanRPCCmd.Flags().IntVar(&cleanOlderThanFlag, "older-than", 0, "Remove entries older than N days") + cacheCleanRPCCmd.Flags().StringVar(&cleanNetworkFlag, "network", "", "Remove entries for a specific network") + cacheCleanRPCCmd.Flags().BoolVar(&cleanAllFlag, "all", false, "Remove all RPC cache entries") + // Add cache command to root + return cacheCmd } var cacheStatusCmd = &cobra.Command{ @@ -232,21 +247,3 @@ At least one filter must be specified. Filters can be combined.`, return nil }, } - -func init() { - // Add subcommands to cache command - cacheCmd.AddCommand(cacheStatusCmd) - cacheCmd.AddCommand(cacheCleanCmd) - cacheCmd.AddCommand(cacheClearCmd) - cacheCmd.AddCommand(cacheCleanRPCCmd) - - // Add flags - cacheCleanCmd.Flags().BoolVarP(&cacheForceFlag, "force", "f", false, "Skip confirmation prompt") - cacheClearCmd.Flags().BoolVarP(&cacheForceFlag, "force", "f", false, "Skip confirmation prompt") - cacheCleanRPCCmd.Flags().IntVar(&cleanOlderThanFlag, "older-than", 0, "Remove entries older than N days") - cacheCleanRPCCmd.Flags().StringVar(&cleanNetworkFlag, "network", "", "Remove entries for a specific network") - cacheCleanRPCCmd.Flags().BoolVar(&cleanAllFlag, "all", false, "Remove all RPC cache entries") - - // Add cache command to root - rootCmd.AddCommand(cacheCmd) -} diff --git a/internal/cmd/compare.go b/internal/cmd/compare.go index 95369eb2..f6b6d67b 100644 --- a/internal/cmd/compare.go +++ b/internal/cmd/compare.go @@ -38,11 +38,12 @@ var ( ) // compareCmd implements `erst compare`. -var compareCmd = &cobra.Command{ - Use: "compare ", - GroupID: "testing", - Short: "Compare replay: local WASM vs on-chain WASM side-by-side", - Long: `Simultaneously replay a transaction against a local WASM file and the on-chain +func NewCompareCmd() *cobra.Command { + compareCmd := &cobra.Command{ + Use: "compare ", + GroupID: "testing", + Short: "Compare replay: local WASM vs on-chain WASM side-by-side", + Long: `Simultaneously replay a transaction against a local WASM file and the on-chain contract, then display a side-by-side diff of events, diagnostic output, budget usage, and divergent call paths. @@ -64,29 +65,27 @@ Examples: # Override the protocol version used for both passes erst compare --wasm ./contract.wasm --protocol-version 22`, - Args: cobra.ExactArgs(1), - PreRunE: func(cmd *cobra.Command, args []string) error { - if cmpLocalWasmFlag == "" { - return errors.WrapValidationError("--wasm flag is required for compare mode") - } - if _, statErr := os.Stat(cmpLocalWasmFlag); os.IsNotExist(statErr) { - return errors.WrapValidationError(fmt.Sprintf("WASM file not found: %s", cmpLocalWasmFlag)) - } - if validateErr := rpc.ValidateTransactionHash(args[0]); validateErr != nil { - return errors.WrapValidationError(fmt.Sprintf("invalid transaction hash: %v", validateErr)) - } - switch rpc.Network(cmpNetworkFlag) { - case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: - // valid - default: - return errors.WrapInvalidNetwork(cmpNetworkFlag) - } - return nil - }, - RunE: runCompare, -} - -func init() { + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + if cmpLocalWasmFlag == "" { + return errors.WrapValidationError("--wasm flag is required for compare mode") + } + if _, statErr := os.Stat(cmpLocalWasmFlag); os.IsNotExist(statErr) { + return errors.WrapValidationError(fmt.Sprintf("WASM file not found: %s", cmpLocalWasmFlag)) + } + if validateErr := rpc.ValidateTransactionHash(args[0]); validateErr != nil { + return errors.WrapValidationError(fmt.Sprintf("invalid transaction hash: %v", validateErr)) + } + switch rpc.Network(cmpNetworkFlag) { + case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: + // valid + default: + return errors.WrapInvalidNetwork(cmpNetworkFlag) + } + return nil + }, + RunE: runCompare, + } compareCmd.Flags().StringVarP(&cmpNetworkFlag, "network", "n", string(rpc.Mainnet), "Stellar network (testnet, mainnet, futurenet)") compareCmd.Flags().StringVar(&cmpRPCURLFlag, "rpc-url", "", @@ -109,7 +108,7 @@ func init() { "Override protocol version for both simulation passes (20, 21, 22, …)") _ = compareCmd.RegisterFlagCompletionFunc("network", completeNetworkFlag) _ = compareCmd.RegisterFlagCompletionFunc("theme", completeThemeFlag) - rootCmd.AddCommand(compareCmd) + return compareCmd } // ─── main handler ───────────────────────────────────────────────────────────── diff --git a/internal/cmd/completion.go b/internal/cmd/completion.go index 5a9ed5d3..0aea66f6 100644 --- a/internal/cmd/completion.go +++ b/internal/cmd/completion.go @@ -10,11 +10,12 @@ import ( ) // completionCmd represents the completion command -var completionCmd = &cobra.Command{ - Use: "completion [bash|zsh|fish|powershell]", - GroupID: "utility", - Short: "Generate completion script for your shell", - Long: `To load completions: +func NewCompletionCmd() *cobra.Command { + completionCmd := &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + GroupID: "utility", + Short: "Generate completion script for your shell", + Long: `To load completions: Bash: @@ -52,23 +53,21 @@ PowerShell: PS> erst completion powershell > erst.ps1 # and source this file from your PowerShell profile. `, - DisableFlagsInUseLine: true, - ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, - Args: cobra.ExactValidArgs(1), - Run: func(cmd *cobra.Command, args []string) { - switch args[0] { - case "bash": - cmd.Root().GenBashCompletionV2(os.Stdout, true) - case "zsh": - cmd.Root().GenZshCompletion(os.Stdout) - case "fish": - cmd.Root().GenFishCompletion(os.Stdout, true) - case "powershell": - cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) - } - }, -} - -func init() { - rootCmd.AddCommand(completionCmd) + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.ExactValidArgs(1), + Run: func(cmd *cobra.Command, args []string) { + switch args[0] { + case "bash": + cmd.Root().GenBashCompletionV2(os.Stdout, true) + case "zsh": + cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + } + }, + } + return completionCmd } diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 615848c7..bfb7ed92 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -26,11 +26,12 @@ var ( daemonOTLPURL string ) -var daemonCmd = &cobra.Command{ - Use: "daemon", - GroupID: "development", - Short: "Start JSON-RPC server for remote debugging", - Long: `Start a JSON-RPC 2.0 server that exposes ERST functionality for remote tools and IDEs. +func NewDaemonCmd() *cobra.Command { + daemonCmd := &cobra.Command{ + Use: "daemon", + GroupID: "development", + Short: "Start JSON-RPC server for remote debugging", + Long: `Start a JSON-RPC 2.0 server that exposes ERST functionality for remote tools and IDEs. Endpoints: - debug_transaction: Debug a failed transaction @@ -39,78 +40,74 @@ Endpoints: Example: erst daemon --port 8080 --network testnet erst daemon --port 8080 --auth-token secret123`, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + // Initialize OpenTelemetry if enabled + var cleanup func() + if daemonTracing { + var err error + cleanup, err = telemetry.Init(ctx, telemetry.Config{ + Enabled: true, + ExporterURL: daemonOTLPURL, + ServiceName: "erst-daemon", + }) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to initialize telemetry: %v", err)) + } + defer cleanup() + } + + // Validate network + switch rpc.Network(daemonNetwork) { + case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: + default: + return errors.WrapInvalidNetwork(daemonNetwork) + } - // Initialize OpenTelemetry if enabled - var cleanup func() - if daemonTracing { - var err error - cleanup, err = telemetry.Init(ctx, telemetry.Config{ - Enabled: true, - ExporterURL: daemonOTLPURL, - ServiceName: "erst-daemon", + // Create server + server, err := daemon.NewServer(daemon.Config{ + Port: daemonPort, + Network: daemonNetwork, + RPCURL: daemonRPCURL, + AuthToken: daemonAuthToken, }) if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to initialize telemetry: %v", err)) + return errors.WrapValidationError(fmt.Sprintf("failed to create server: %v", err)) } - defer cleanup() - } - - // Validate network - switch rpc.Network(daemonNetwork) { - case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: - default: - return errors.WrapInvalidNetwork(daemonNetwork) - } - - // Create server - server, err := daemon.NewServer(daemon.Config{ - Port: daemonPort, - Network: daemonNetwork, - RPCURL: daemonRPCURL, - AuthToken: daemonAuthToken, - }) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to create server: %v", err)) - } - - // Setup graceful shutdown - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - // Handle interrupt signals - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - go func() { - <-sigChan - fmt.Println("\nReceived interrupt signal, shutting down...") - cancel() - }() - fmt.Printf("Starting ERST daemon on port %s\n", daemonPort) - fmt.Printf("Network: %s\n", daemonNetwork) - if daemonRPCURL != "" { - fmt.Printf("RPC URL: %s\n", daemonRPCURL) - } - if daemonAuthToken != "" { - fmt.Println("Authentication: enabled") - } - - // Start server - return server.Start(ctx, daemonPort) - }, -} + // Setup graceful shutdown + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Handle interrupt signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigChan + fmt.Println("\nReceived interrupt signal, shutting down...") + cancel() + }() + + fmt.Printf("Starting ERST daemon on port %s\n", daemonPort) + fmt.Printf("Network: %s\n", daemonNetwork) + if daemonRPCURL != "" { + fmt.Printf("RPC URL: %s\n", daemonRPCURL) + } + if daemonAuthToken != "" { + fmt.Println("Authentication: enabled") + } -func init() { + // Start server + return server.Start(ctx, daemonPort) + }, + } daemonCmd.Flags().StringVarP(&daemonPort, "port", "p", "8080", "Port to listen on") daemonCmd.Flags().StringVarP(&daemonNetwork, "network", "n", string(rpc.Mainnet), "Stellar network to use (testnet, mainnet, futurenet)") daemonCmd.Flags().StringVar(&daemonRPCURL, "rpc-url", "", "Custom Horizon RPC URL to use") daemonCmd.Flags().StringVar(&daemonAuthToken, "auth-token", "", "Authentication token for API access") daemonCmd.Flags().BoolVar(&daemonTracing, "tracing", false, "Enable OpenTelemetry tracing") daemonCmd.Flags().StringVar(&daemonOTLPURL, "otlp-url", "http://localhost:4318", "OTLP exporter URL") - _ = daemonCmd.RegisterFlagCompletionFunc("network", completeNetworkFlag) - - rootCmd.AddCommand(daemonCmd) + return daemonCmd } diff --git a/internal/cmd/dce.go b/internal/cmd/dce.go index d0b2a494..9a235918 100644 --- a/internal/cmd/dce.go +++ b/internal/cmd/dce.go @@ -13,10 +13,11 @@ import ( var dceOutput string -var dceCmd = &cobra.Command{ - Use: "dce ", - Short: "Eliminate dead code from a WASM binary", - Long: `Analyze a compiled WASM binary, build a call graph from exported functions, +func NewDceCmd() *cobra.Command { + dceCmd := &cobra.Command{ + Use: "dce ", + Short: "Eliminate dead code from a WASM binary", + Long: `Analyze a compiled WASM binary, build a call graph from exported functions, and strip unreachable functions to reduce contract size. Without -o, performs a dry run and prints statistics only. @@ -24,8 +25,11 @@ Without -o, performs a dry run and prints statistics only. Examples: erst dce ./contract.wasm -o ./contract-optimized.wasm erst dce ./contract.wasm`, - Args: cobra.ExactArgs(1), - RunE: dceExec, + Args: cobra.ExactArgs(1), + RunE: dceExec, + } + dceCmd.Flags().StringVarP(&dceOutput, "output", "o", "", "Output file path (omit for dry run)") + return dceCmd } func dceExec(cmd *cobra.Command, args []string) error { @@ -61,8 +65,3 @@ func dceExec(cmd *cobra.Command, args []string) error { return nil } - -func init() { - dceCmd.Flags().StringVarP(&dceOutput, "output", "o", "", "Output file path (omit for dry run)") - rootCmd.AddCommand(dceCmd) -} diff --git a/internal/cmd/debug.go b/internal/cmd/debug.go index bb0df469..3287c953 100644 --- a/internal/cmd/debug.go +++ b/internal/cmd/debug.go @@ -1,1431 +1,1429 @@ -// Copyright 2026 Erst Users -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "bufio" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "log/slog" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/dotandev/hintents/internal/config" - "github.com/dotandev/hintents/internal/decenstorage" - "github.com/dotandev/hintents/internal/decoder" - "github.com/dotandev/hintents/internal/errors" - "github.com/dotandev/hintents/internal/logger" - "github.com/dotandev/hintents/internal/lto" - "github.com/dotandev/hintents/internal/rpc" - "github.com/dotandev/hintents/internal/security" - "github.com/dotandev/hintents/internal/session" - "github.com/dotandev/hintents/internal/simulator" - "github.com/dotandev/hintents/internal/snapshot" - "github.com/dotandev/hintents/internal/telemetry" - "github.com/dotandev/hintents/internal/tokenflow" - "github.com/dotandev/hintents/internal/visualizer" - "github.com/dotandev/hintents/internal/wat" - "github.com/dotandev/hintents/internal/watch" - - "github.com/spf13/cobra" - "github.com/stellar/go-stellar-sdk/xdr" - "go.opentelemetry.io/otel/attribute" -) - -var ( - networkFlag string - rpcURLFlag string - rpcTokenFlag string - tracingEnabled bool - otlpExporterURL string - generateTrace bool - traceOutputFile string - snapshotFlag string - compareNetworkFlag string - verbose bool - wasmPath string - wasmOptimizeFlag bool - args []string - themeFlag string - noCacheFlag bool - demoMode bool - watchFlag bool - watchTimeoutFlag int - hotReloadFlag bool - hotReloadInterval time.Duration - protocolVersionFlag uint32 - auditKeyFlag string - publishIPFSFlag bool - publishArweaveFlag bool - ipfsNodeFlag string - arweaveGatewayFlag string - arweaveWalletFlag string - mockTimeFlag int64 - mockBaseFeeFlag uint32 - mockGasPriceFlag uint64 - asyncFlag bool - asyncTimeoutFlag int -) - -// DebugCommand holds dependencies for the debug command -type DebugCommand struct { - Runner simulator.RunnerInterface -} - -// NewDebugCommand creates a new debug command with dependencies -func NewDebugCommand(runner simulator.RunnerInterface) *cobra.Command { - debugCmd := &DebugCommand{Runner: runner} - return debugCmd.createCommand() -} - -func (d *DebugCommand) createCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "debug ", - Short: "Debug a failed Soroban transaction", - Long: `Fetch a transaction envelope from the Stellar network and prepare it for simulation. - -Example: - erst debug 5c0a1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab - erst debug --network testnet `, - Args: cobra.ExactArgs(1), - PreRunE: func(cmd *cobra.Command, args []string) error { - // Validate network flag - switch rpc.Network(networkFlag) { - case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: - return nil - default: - return errors.WrapInvalidNetwork(networkFlag) - } - }, - RunE: d.runDebug, - } - - // Set up flags - cmd.Flags().StringVarP(&networkFlag, "network", "n", string(rpc.Mainnet), "Stellar network to use (testnet, mainnet, futurenet)") - cmd.Flags().StringVar(&rpcURLFlag, "rpc-url", "", "Custom Horizon RPC URL to use") - cmd.Flags().StringVar(&rpcTokenFlag, "rpc-token", "", "RPC authentication token (can also use ERST_RPC_TOKEN env var)") - - return cmd -} - -func (d *DebugCommand) runDebug(cmd *cobra.Command, args []string) error { - txHash := args[0] - - token := rpcTokenFlag - if token == "" { - token = os.Getenv("ERST_RPC_TOKEN") - } - if token == "" { - cfg, err := config.Load() - if err == nil && cfg.RPCToken != "" { - token = cfg.RPCToken - } - } - - opts := []rpc.ClientOption{ - rpc.WithNetwork(rpc.Network(networkFlag)), - rpc.WithToken(token), - } - if rpcURLFlag != "" { - opts = append(opts, rpc.WithHorizonURL(rpcURLFlag)) - } - - client, err := rpc.NewClient(opts...) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to create client: %v", err)) - } - - fmt.Printf("Debugging transaction: %s\n", txHash) - fmt.Printf("Network: %s\n", networkFlag) - if rpcURLFlag != "" { - fmt.Printf("RPC URL: %s\n", rpcURLFlag) - } - - // Fetch transaction details - resp, err := client.GetTransaction(cmd.Context(), txHash) - if err != nil { - return errors.WrapRPCConnectionFailed(err) - } - - fmt.Printf("Transaction fetched successfully. Envelope size: %d bytes\n", len(resp.EnvelopeXdr)) - - // TODO: Use d.Runner for simulation when ready - // simReq := &simulator.SimulationRequest{ - // EnvelopeXdr: resp.EnvelopeXdr, - // ResultMetaXdr: resp.ResultMetaXdr, - // } - // simResp, err := d.Runner.Run(simReq) - - return nil -} - -var debugCmd = &cobra.Command{ - Use: "debug ", - Short: "Debug a failed Soroban transaction", - Long: `Fetch and simulate a Soroban transaction to debug failures and analyze execution. - -This command retrieves the transaction envelope from the Stellar network, runs it -through the local simulator, and displays detailed execution traces including: - - Transaction status and error messages - - Contract events and diagnostic logs - - Token flows (XLM and Soroban assets) - - Execution metadata and state changes - -The simulation results are stored in a session that can be saved for later analysis. - -Local WASM Replay Mode: - Use --wasm flag to test contracts locally without network data.`, - Example: ` # Debug a transaction on mainnet - erst debug 5c0a1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab - - # Debug on testnet - erst debug --network testnet abc123...def789 - - # Debug and compare results between networks - erst debug --network mainnet --compare-network testnet abc123...def789 - - # Debug and save the session - erst debug abc123...def789 && erst session save - - # Compare execution across networks - erst debug --network testnet --compare-network mainnet - - # Local WASM replay (no network required) - erst debug --wasm ./contract.wasm --args "arg1" --args "arg2" - - # Demo mode (test color output, no network required) - erst debug --demo`, - Args: cobra.MaximumNArgs(1), - PreRunE: func(cmd *cobra.Command, args []string) error { - if hotReloadFlag && wasmPath == "" { - return errors.WrapValidationError("--hot-reload requires --wasm") - } - - // Demo mode or local WASM replay don't need transaction hash - if demoMode || wasmPath != "" { - return nil - } - - if len(args) == 0 { - return errors.WrapValidationError("transaction hash is required when not using --wasm or --demo flag") - } - - if err := rpc.ValidateTransactionHash(args[0]); err != nil { - return errors.WrapValidationError(fmt.Sprintf("invalid transaction hash format: %v", err)) - } - - if !cmd.Flags().Changed("network") { - token := rpcTokenFlag - if token == "" { - token = os.Getenv("ERST_RPC_TOKEN") - } - probeCtx, probeCancel := context.WithTimeout(cmd.Context(), 5*time.Second) - defer probeCancel() - if resolved, err := rpc.ResolveNetwork(probeCtx, args[0], token); err == nil { - networkFlag = string(resolved) - fmt.Printf("Resolved network: %s\n", networkFlag) - } - } - - // Validate network flag - switch rpc.Network(networkFlag) { - case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: - // valid - default: - return errors.WrapInvalidNetwork(networkFlag) - } - - // Validate compare network flag if present - if compareNetworkFlag != "" { - switch rpc.Network(compareNetworkFlag) { - case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: - // valid - default: - return errors.WrapInvalidNetwork(compareNetworkFlag) - } - } - return nil - }, - RunE: func(cmd *cobra.Command, cmdArgs []string) error { - if verbose { - logger.SetLevel(slog.LevelInfo) - } else { - logger.SetLevel(slog.LevelWarn) - } - - // Apply theme if specified, otherwise auto-detect - if themeFlag != "" { - visualizer.SetTheme(visualizer.Theme(themeFlag)) - } else { - visualizer.SetTheme(visualizer.DetectTheme()) - } - - // Demo mode: print sample output for testing color detection (no network) - if demoMode { - return runDemoMode(cmdArgs) - } - - // Local WASM replay mode - if wasmPath != "" { - return runLocalWasmReplay() - } - - // Network transaction replay mode - ctx := cmd.Context() - txHash := cmdArgs[0] - - // Load persisted viewer state for this transaction (best-effort). - var uiStore *session.UIStateStore - if s, err := session.NewUIStateStore(); err == nil { - uiStore = s - defer uiStore.Close() - if prev, err := uiStore.LoadSectionState(ctx, txHash); err == nil && len(prev) > 0 { - fmt.Printf("Restoring viewer state: last session showed [%s] for this transaction.\n", strings.Join(prev, ", ")) - } - } - - // Initialize OpenTelemetry if enabled - if tracingEnabled { - cleanup, err := telemetry.Init(ctx, telemetry.Config{ - Enabled: true, - ExporterURL: otlpExporterURL, - ServiceName: "erst", - }) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to initialize telemetry: %v", err)) - } - defer cleanup() - } - - // Start root span - tracer := telemetry.GetTracer() - ctx, span := tracer.Start(ctx, "debug_transaction") - span.SetAttributes( - attribute.String("transaction.hash", txHash), - attribute.String("network", networkFlag), - ) - defer span.End() - - var horizonURL string - token := rpcTokenFlag - if token == "" { - token = os.Getenv("ERST_RPC_TOKEN") - } - if token == "" { - if cfg, err := config.Load(); err == nil && cfg.RPCToken != "" { - token = cfg.RPCToken - } - } - - opts := []rpc.ClientOption{ - rpc.WithNetwork(rpc.Network(networkFlag)), - rpc.WithToken(token), - } - - if rpcURLFlag != "" { - urls := strings.Split(rpcURLFlag, ",") - for i := range urls { - urls[i] = strings.TrimSpace(urls[i]) - } - opts = append(opts, rpc.WithAltURLs(urls)) - horizonURL = urls[0] - } else { - cfg, err := config.Load() - if err == nil { - if len(cfg.RpcUrls) > 0 { - opts = append(opts, rpc.WithAltURLs(cfg.RpcUrls)) - horizonURL = cfg.RpcUrls[0] - } else if cfg.RpcUrl != "" { - opts = append(opts, rpc.WithHorizonURL(cfg.RpcUrl)) - horizonURL = cfg.RpcUrl - } - } - } - - client, err := rpc.NewClient(opts...) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to create client: %v", err)) - } - - if horizonURL == "" { - // Extract horizon URL from valid client if not explicitly set - horizonURL = client.HorizonURL - } - - if noCacheFlag { - client.CacheEnabled = false - fmt.Println("🚫 Cache disabled by --no-cache flag") - } - - fmt.Printf("Debugging transaction: %s\n", txHash) - fmt.Printf("Primary Network: %s\n", networkFlag) - if compareNetworkFlag != "" { - fmt.Printf("Comparing against Network: %s\n", compareNetworkFlag) - } - - // Fetch transaction details - if watchFlag { - spinner := watch.NewSpinner() - poller := watch.NewPoller(watch.PollerConfig{ - InitialInterval: 1 * time.Second, - MaxInterval: 10 * time.Second, - TimeoutDuration: time.Duration(watchTimeoutFlag) * time.Second, - }) - - spinner.Start("Waiting for transaction to appear on-chain...") - - result, err := poller.Poll(ctx, func(pollCtx context.Context) (interface{}, error) { - _, pollErr := client.GetTransaction(pollCtx, txHash) - if pollErr != nil { - return nil, pollErr - } - return true, nil - }, nil) - - if err != nil { - spinner.StopWithError("Failed to poll for transaction") - return errors.WrapSimulationLogicError(fmt.Sprintf("watch mode error: %v", err)) - } - - if !result.Found { - spinner.StopWithError("Transaction not found within timeout") - return errors.WrapTransactionNotFound(fmt.Errorf("not found after %d seconds", watchTimeoutFlag)) - } - - spinner.StopWithMessage("Transaction found! Starting debug...") - } - - fmt.Printf("Fetching transaction: %s\n", txHash) - resp, err := client.GetTransaction(ctx, txHash) - if err != nil { - return errors.WrapRPCConnectionFailed(err) - } - - fmt.Printf("Transaction fetched successfully. Envelope size: %d bytes\n", len(resp.EnvelopeXdr)) - - // Extract ledger keys for replay - keys, err := extractLedgerKeys(resp.ResultMetaXdr) - if err != nil { - return errors.WrapUnmarshalFailed(err, "result meta") - } - - // Initialize Simulator Runner - runner, err := simulator.NewRunnerWithMockTime("", tracingEnabled, mockTimeFlag) - if err != nil { - return errors.WrapSimulatorNotFound(err.Error()) - } - - // Determine timestamps to simulate - timestamps := []int64{TimestampFlag} - if WindowFlag > 0 && TimestampFlag > 0 { - // Simulate 5 steps across the window - step := WindowFlag / 4 - for i := 1; i <= 4; i++ { - timestamps = append(timestamps, TimestampFlag+int64(i)*step) - } - } - - var lastSimResp *simulator.SimulationResponse - - for _, ts := range timestamps { - if len(timestamps) > 1 { - fmt.Printf("\n--- Simulating at Timestamp: %d ---\n", ts) - } - - var simResp *simulator.SimulationResponse - var ledgerEntries map[string]string - - if compareNetworkFlag == "" { - // Single Network Run - if snapshotFlag != "" { - snap, err := snapshot.Load(snapshotFlag) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to load snapshot: %v", err)) - } - ledgerEntries = snap.ToMap() - fmt.Printf("Loaded %d ledger entries from snapshot\n", len(ledgerEntries)) - } else { - // Try to extract from metadata first, fall back to fetching - ledgerEntries, err = rpc.ExtractLedgerEntriesFromMeta(resp.ResultMetaXdr) - if err != nil { - logger.Logger.Warn("Failed to extract ledger entries from metadata, fetching from network", "error", err) - ledgerEntries, err = client.GetLedgerEntries(ctx, keys) - if err != nil { - return errors.WrapRPCConnectionFailed(err) - } - } else { - logger.Logger.Info("Extracted ledger entries for simulation", "count", len(ledgerEntries)) - } - } - - fmt.Printf("Running simulation on %s...\n", networkFlag) - simReq := &simulator.SimulationRequest{ - EnvelopeXdr: resp.EnvelopeXdr, - ResultMetaXdr: resp.ResultMetaXdr, - LedgerEntries: ledgerEntries, - Timestamp: ts, - ProtocolVersion: nil, - } - - // Apply protocol version override if specified - if protocolVersionFlag > 0 { - if err := simulator.Validate(protocolVersionFlag); err != nil { - return fmt.Errorf("invalid protocol version %d: %w", protocolVersionFlag, err) - } - simReq.ProtocolVersion = &protocolVersionFlag - fmt.Printf("Using protocol version override: %d\n", protocolVersionFlag) - } - applySimulationFeeMocks(simReq) - - simResp, err = runner.Run(ctx, simReq) - if err != nil { - return errors.WrapSimulationFailed(err, "") - } - printSimulationResult(networkFlag, simResp) - // Fetch contract bytecode on demand for any contract calls in the trace; cache via RPC client - if client != nil && simResp != nil && len(simResp.DiagnosticEvents) > 0 { - contractIDs := collectContractIDsFromDiagnosticEvents(simResp.DiagnosticEvents) - if len(contractIDs) > 0 { - _, _ = rpc.FetchBytecodeForTraceContractCalls(ctx, client, contractIDs, nil) - } - } - } else { - // Comparison Run - var wg sync.WaitGroup - var primaryResult, compareResult *simulator.SimulationResponse - var primaryErr, compareErr error - - wg.Add(2) - go func() { - defer wg.Done() - var entries map[string]string - var extractErr error - entries, extractErr = rpc.ExtractLedgerEntriesFromMeta(resp.ResultMetaXdr) - if extractErr != nil { - entries, extractErr = client.GetLedgerEntries(ctx, keys) - if extractErr != nil { - primaryErr = extractErr - return - } - } - primaryReq := &simulator.SimulationRequest{ - EnvelopeXdr: resp.EnvelopeXdr, - ResultMetaXdr: resp.ResultMetaXdr, - LedgerEntries: entries, - Timestamp: ts, - } - applySimulationFeeMocks(primaryReq) - primaryResult, primaryErr = runner.Run(ctx, primaryReq) - }() - - go func() { - defer wg.Done() - compareOpts := []rpc.ClientOption{ - rpc.WithNetwork(rpc.Network(compareNetworkFlag)), - rpc.WithToken(rpcTokenFlag), - } - compareClient, clientErr := rpc.NewClient(compareOpts...) - if clientErr != nil { - compareErr = errors.WrapValidationError(fmt.Sprintf("failed to create compare client: %v", clientErr)) - return - } - if noCacheFlag { - compareClient.CacheEnabled = false - } - - compareResp, txErr := compareClient.GetTransaction(ctx, txHash) - if txErr != nil { - compareErr = errors.WrapRPCConnectionFailed(txErr) - return - } - - entries, extractErr := rpc.ExtractLedgerEntriesFromMeta(compareResp.ResultMetaXdr) - if extractErr != nil { - entries, extractErr = compareClient.GetLedgerEntries(ctx, keys) - if extractErr != nil { - compareErr = extractErr - return - } - } - - compareReq := &simulator.SimulationRequest{ - EnvelopeXdr: resp.EnvelopeXdr, - ResultMetaXdr: compareResp.ResultMetaXdr, - LedgerEntries: entries, - Timestamp: ts, - } - applySimulationFeeMocks(compareReq) - compareResult, compareErr = runner.Run(ctx, compareReq) - }() - - wg.Wait() - if primaryErr != nil { - return errors.WrapRPCConnectionFailed(primaryErr) - } - if compareErr != nil { - return errors.WrapRPCConnectionFailed(compareErr) - } - // Fetch contract bytecode on demand for contract calls in the trace; cache via RPC client - if client != nil && primaryResult != nil && len(primaryResult.DiagnosticEvents) > 0 { - contractIDs := collectContractIDsFromDiagnosticEvents(primaryResult.DiagnosticEvents) - if len(contractIDs) > 0 { - _, _ = rpc.FetchBytecodeForTraceContractCalls(ctx, client, contractIDs, nil) - } - } - - simResp = primaryResult // Use primary for further analysis - printSimulationResult(networkFlag, primaryResult) - printSimulationResult(compareNetworkFlag, compareResult) - diffResults(primaryResult, compareResult, networkFlag, compareNetworkFlag) - } - lastSimResp = simResp - } - - if lastSimResp == nil { - return errors.WrapSimulationLogicError("no simulation results generated") - } - - // Analysis: Error Suggestions (Heuristic-based) - if len(lastSimResp.Events) > 0 { - suggestionEngine := decoder.NewSuggestionEngine() - - // Load config to get MaxTraceDepth - cfg, _ := config.Load() - maxDepth := 50 - if cfg != nil { - maxDepth = cfg.MaxTraceDepth - } - - // Decode events for analysis - callTree, err := decoder.DecodeEvents(lastSimResp.Events, maxDepth) - if err == nil && callTree != nil { - suggestions := suggestionEngine.AnalyzeCallTree(callTree) - if len(suggestions) > 0 { - fmt.Print(decoder.FormatSuggestions(suggestions)) - } - } - } - - // Analysis: Security - fmt.Printf("\n=== Security Analysis ===\n") - secDetector := security.NewDetector() - findings := secDetector.Analyze(resp.EnvelopeXdr, resp.ResultMetaXdr, lastSimResp.Events, lastSimResp.Logs) - if len(findings) == 0 { - fmt.Printf("%s No security issues detected\n", visualizer.Success()) - } else { - verifiedCount := 0 - heuristicCount := 0 - - for _, finding := range findings { - if finding.Type == security.FindingVerifiedRisk { - verifiedCount++ - } else { - heuristicCount++ - } - } - - if verifiedCount > 0 { - fmt.Printf("\n[!] VERIFIED SECURITY RISKS: %d\n", verifiedCount) - } - if heuristicCount > 0 { - fmt.Printf("* HEURISTIC WARNINGS: %d\n", heuristicCount) - } - - fmt.Printf("\nFindings:\n") - for i, finding := range findings { - icon := "*" - if finding.Type == security.FindingVerifiedRisk { - icon = "[!]" - } - fmt.Printf("%d. %s [%s] %s - %s\n", i+1, icon, finding.Type, finding.Severity, finding.Title) - fmt.Printf(" %s\n", finding.Description) - if finding.Evidence != "" { - fmt.Printf(" Evidence: %s\n", finding.Evidence) - } - } - } - - // Analysis: Token Flows - hasTokenFlows := false - if report, err := tokenflow.BuildReport(resp.EnvelopeXdr, resp.ResultMetaXdr); err == nil && len(report.Agg) > 0 { - hasTokenFlows = true - fmt.Printf("\nToken Flow Summary:\n") - for _, line := range report.SummaryLines() { - fmt.Printf(" %s\n", line) - } - fmt.Printf("\nToken Flow Chart (Mermaid):\n") - fmt.Println(report.MermaidFlowchart()) - } - - // Persist viewer state so the next debug of this transaction restores context. - if uiStore != nil { - _ = uiStore.SaveSectionState(ctx, txHash, collectVisibleSections(lastSimResp, findings, hasTokenFlows)) - } - - // Session Management - simReq := &simulator.SimulationRequest{ - EnvelopeXdr: resp.EnvelopeXdr, - ResultMetaXdr: resp.ResultMetaXdr, - } - applySimulationFeeMocks(simReq) - simReqJSON, err := json.Marshal(simReq) - if err != nil { - fmt.Printf("Warning: failed to serialize simulation data: %v\n", err) - } - simRespJSON, err := json.Marshal(lastSimResp) - if err != nil { - fmt.Printf("Warning: failed to serialize simulation results: %v\n", err) - } - - sessionData := &session.SessionData{ - ID: session.GenerateID(txHash), - CreatedAt: time.Now(), - LastAccessAt: time.Now(), - Status: "active", - Network: networkFlag, - HorizonURL: horizonURL, - TxHash: txHash, - EnvelopeXdr: resp.EnvelopeXdr, - ResultXdr: resp.ResultXdr, - ResultMetaXdr: resp.ResultMetaXdr, - SimRequestJSON: string(simReqJSON), - SimResponseJSON: string(simRespJSON), - ErstVersion: Version, - SchemaVersion: session.SchemaVersion, - } - SetCurrentSession(sessionData) - fmt.Printf("\nSession created: %s\n", sessionData.ID) - fmt.Printf("Run 'erst session save' to persist this session.\n") - - // Publish signed audit trail to decentralised storage when requested. - if publishIPFSFlag || publishArweaveFlag { - if auditKeyFlag == "" { - return errors.WrapCliArgumentRequired("audit-key") - } - auditLog, auditErr := Generate( - txHash, - resp.EnvelopeXdr, - resp.ResultMetaXdr, - lastSimResp.Events, - lastSimResp.Logs, - auditKeyFlag, - nil, - ) - if auditErr != nil { - return fmt.Errorf("failed to generate audit log: %w", auditErr) - } - auditBytes, auditErr := json.Marshal(auditLog) - if auditErr != nil { - return fmt.Errorf("failed to marshal audit log: %w", auditErr) - } - - pub := decenstorage.New(decenstorage.PublishConfig{ - IPFSNode: ipfsNodeFlag, - ArweaveGateway: arweaveGatewayFlag, - ArweaveWallet: arweaveWalletFlag, - }) - - fmt.Printf("\n=== Decentralised Storage ===\n") - - if publishIPFSFlag { - result, ipfsErr := pub.PublishIPFS(ctx, auditBytes) - if ipfsErr != nil { - fmt.Printf("IPFS publish failed: %v\n", ipfsErr) - } else { - fmt.Printf("IPFS CID : %s\n", result.CID) - fmt.Printf("IPFS URL : %s\n", result.URL) - } - } - - if publishArweaveFlag { - result, arErr := pub.PublishArweave(ctx, auditBytes) - if arErr != nil { - fmt.Printf("Arweave publish failed: %v\n", arErr) - } else { - fmt.Printf("Arweave TXID : %s\n", result.TXID) - fmt.Printf("Arweave URL : %s\n", result.URL) - } - } - } - - return nil - }, -} - -// runDemoMode prints sample output without network/WASM - for testing color detection. -func runDemoMode(cmdArgs []string) error { - txHash := "5c0a1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab" - if len(cmdArgs) > 0 && len(cmdArgs[0]) == 64 { - txHash = cmdArgs[0] - } - - fmt.Printf("Fetching transaction: %s\n", txHash) - fmt.Printf("Transaction fetched successfully. Envelope size: 256 bytes\n") - fmt.Printf("\n--- Result for %s ---\n", networkFlag) - fmt.Printf("Status: success\n") - fmt.Printf("\nResource Usage:\n") - fmt.Printf(" CPU Instructions: 12345\n") - fmt.Printf(" Memory Bytes: 1024\n") - fmt.Printf(" Operations: 5\n") - fmt.Printf("\nEvents: 2, Logs: 3\n") - fmt.Printf("\n=== Security Analysis ===\n") - fmt.Printf("%s No security issues detected\n", visualizer.Success()) - fmt.Printf("\nToken Flow Summary:\n") - fmt.Printf(" %s XLM transferred\n", visualizer.Symbol("arrow_r")) - fmt.Printf("\nSession ready. Use 'erst session save' to persist.\n") - return nil -} - -func runLocalWasmReplay() error { - fmt.Printf("%s WARNING: Using Mock State (not mainnet data)\n", visualizer.Warning()) - fmt.Println() - - // Verify WASM file exists - if _, err := os.Stat(wasmPath); os.IsNotExist(err) { - return errors.WrapValidationError(fmt.Sprintf("WASM file not found: %s", wasmPath)) - } - - fmt.Printf("%s Local WASM Replay Mode\n", visualizer.Symbol("wrench")) - fmt.Printf("WASM File: %s\n", wasmPath) - fmt.Printf("Arguments: %v\n", args) - fmt.Println() - - // Check for LTO in the project that produced the WASM - checkLTOWarning(wasmPath) - - // Create simulator runner - runner, err := simulator.NewRunner("", tracingEnabled) - if err != nil { - return errors.WrapSimulatorNotFound(err.Error()) - } - defer runner.Close() - - ctx := context.Background() - if hotReloadFlag { - return runLocalWasmReplaySession(ctx, runner, os.Stdin, os.Stdout) - } - return runLocalWasmReplayOnce(ctx, runner, false) -} - -func newLocalWasmSimulationRequest(forceNoCache bool) *simulator.SimulationRequest { - req := &simulator.SimulationRequest{ - EnvelopeXdr: "", // Empty for local replay - ResultMetaXdr: "", // Empty for local replay - LedgerEntries: nil, // Mock state will be generated - WasmPath: &wasmPath, - NoCache: noCacheFlag || forceNoCache, - MockArgs: &args, - } - applySimulationFeeMocks(req) - return req -} - -func runLocalWasmReplayOnce(ctx context.Context, runner simulator.RunnerInterface, forceNoCache bool) error { - req := newLocalWasmSimulationRequest(forceNoCache) - - // Run simulation - fmt.Printf("%s Executing contract locally...\n", visualizer.Symbol("play")) - resp, err := runner.Run(ctx, req) - if err != nil { - fmt.Printf("%s Technical failure: %v\n", visualizer.Error(), err) - return err - } - - // Display results - fmt.Println() - if resp.Status == "error" { - fmt.Printf("%s Execution failed\n", visualizer.Error()) - if resp.Error != "" { - fmt.Printf("Error: %s\n", resp.Error) - } - - // Fallback to WAT disassembly if source mapping is unavailable but we have an offset - if resp.SourceLocation == "" && resp.WasmOffset != nil { - fmt.Println() - wasmBytes, err := os.ReadFile(wasmPath) - if err == nil { - fallbackMsg := wat.FormatFallback(wasmBytes, *resp.WasmOffset, 5) - fmt.Println(fallbackMsg) - } - } - } else { - fmt.Printf("%s Execution completed successfully\n", visualizer.Success()) - } - fmt.Println() - - if len(resp.Logs) > 0 { - fmt.Printf("%s Logs:\n", visualizer.Symbol("logs")) - for _, log := range resp.Logs { - fmt.Printf(" %s\n", log) - } - fmt.Println() - } - - if len(resp.Events) > 0 { - fmt.Printf("%s Events:\n", visualizer.Symbol("events")) - for _, event := range resp.Events { - if deprecatedFn, ok := findDeprecatedHostFunction(event); ok { - fmt.Printf(" %s %s %s\n", event, visualizer.Warning(), visualizer.Colorize("deprecated host fn: "+deprecatedFn, "yellow")) - continue - } - fmt.Printf(" %s\n", event) - } - fmt.Println() - } - - if verbose { - fmt.Printf("%s Full Response:\n", visualizer.Symbol("magnify")) - jsonBytes, _ := json.MarshalIndent(resp, "", " ") - fmt.Println(string(jsonBytes)) - } - - return nil -} - -func runLocalWasmReplaySession(ctx context.Context, runner simulator.RunnerInterface, in io.Reader, out io.Writer) error { - fmt.Println("[watcher] Hot reload enabled") - if err := runLocalWasmReplayOnce(ctx, runner, false); err != nil { - return err - } - - initialFP, err := watch.ComputeWasmFingerprint(wasmPath, 5, 50*time.Millisecond) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to fingerprint initial wasm: %v", err)) - } - lastAppliedHash := initialFP.Hash - - cfg := watch.DefaultWasmReloaderConfig(wasmPath, hotReloadInterval) - reloadEvents, reloadErrors, err := watch.StartWasmReloader(ctx, cfg) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to start wasm watcher: %v", err)) - } - fmt.Println("[watcher] Watching for WASM changes") - - reader := bufio.NewReader(in) - var pending *watch.ReloadEvent - - for { - if pending != nil { - choice, promptErr := promptHotReloadChoice(reader, out) - if promptErr != nil { - return promptErr - } - - switch choice { - case 'r': - fmt.Println("[watcher] Re-running simulation with updated WASM") - if err := runLocalWasmReplayOnce(ctx, runner, true); err != nil { - fmt.Printf("[watcher] Re-run failed: %v\n", err) - } else { - lastAppliedHash = pending.Hash - } - case 's': - fmt.Println("[watcher] Reload skipped") - case 'q': - fmt.Println("[watcher] Exiting hot reload session") - return nil - } - pending = drainLatestReloadEvent(reloadEvents, lastAppliedHash) - continue - } - - select { - case <-ctx.Done(): - return nil - case watchErr, ok := <-reloadErrors: - if !ok { - reloadErrors = nil - continue - } - fmt.Printf("[watcher] Warning: %v\n", watchErr) - case event, ok := <-reloadEvents: - if !ok { - return nil - } - if event.Hash == lastAppliedHash { - continue - } - fmt.Println("[watcher] WASM updated (hash changed)") - fmt.Println("[watcher] Reload available") - pending = &event - } - } -} - -func promptHotReloadChoice(reader *bufio.Reader, out io.Writer) (byte, error) { - for { - fmt.Fprint(out, "Re-run simulation? (r = reload, s = skip, q = quit): ") - line, err := reader.ReadString('\n') - if err != nil { - if err == io.EOF && strings.TrimSpace(line) != "" { - // Accept final line without trailing newline. - } else { - return 0, err - } - } - - choice := strings.ToLower(strings.TrimSpace(line)) - switch choice { - case "r", "s", "q": - return choice[0], nil - default: - fmt.Fprintln(out, "Invalid choice. Please enter r, s, or q.") - } - } -} - -func drainLatestReloadEvent(events <-chan watch.ReloadEvent, lastAppliedHash string) *watch.ReloadEvent { - var latest *watch.ReloadEvent - for { - select { - case event, ok := <-events: - if !ok { - return latest - } - if event.Hash == lastAppliedHash { - continue - } - ev := event - latest = &ev - default: - return latest - } - } -} - -func extractLedgerKeys(metaXdr string) ([]string, error) { - data, err := base64.StdEncoding.DecodeString(metaXdr) - if err != nil { - return nil, err - } - - var meta xdr.TransactionResultMeta - if err := xdr.SafeUnmarshal(data, &meta); err != nil { - return nil, err - } - - keysMap := make(map[string]struct{}) - addKey := func(k xdr.LedgerKey) { - b, _ := k.MarshalBinary() - keysMap[base64.StdEncoding.EncodeToString(b)] = struct{}{} - } - - collectChanges := func(changes xdr.LedgerEntryChanges) { - for _, c := range changes { - switch c.Type { - case xdr.LedgerEntryChangeTypeLedgerEntryCreated: - k, err := c.Created.LedgerKey() - if err == nil { - addKey(k) - } - case xdr.LedgerEntryChangeTypeLedgerEntryUpdated: - k, err := c.Updated.LedgerKey() - if err == nil { - addKey(k) - } - case xdr.LedgerEntryChangeTypeLedgerEntryRemoved: - if c.Removed != nil { - addKey(*c.Removed) - } - case xdr.LedgerEntryChangeTypeLedgerEntryState: - k, err := c.State.LedgerKey() - if err == nil { - addKey(k) - } - } - } - } - - // 1. Fee processing changes - collectChanges(meta.FeeProcessing) - - // 2. Transaction apply processing changes - switch meta.TxApplyProcessing.V { - case 0: - if meta.TxApplyProcessing.Operations != nil { - for _, op := range *meta.TxApplyProcessing.Operations { - collectChanges(op.Changes) - } - } - case 1: - if v1 := meta.TxApplyProcessing.V1; v1 != nil { - collectChanges(v1.TxChanges) - for _, op := range v1.Operations { - collectChanges(op.Changes) - } - } - case 2: - if v2 := meta.TxApplyProcessing.V2; v2 != nil { - collectChanges(v2.TxChangesBefore) - collectChanges(v2.TxChangesAfter) - for _, op := range v2.Operations { - collectChanges(op.Changes) - } - } - case 3: - if v3 := meta.TxApplyProcessing.V3; v3 != nil { - collectChanges(v3.TxChangesBefore) - collectChanges(v3.TxChangesAfter) - for _, op := range v3.Operations { - collectChanges(op.Changes) - } - } - } - - res := make([]string, 0, len(keysMap)) - for k := range keysMap { - res = append(res, k) - } - return res, nil -} - -// collectContractIDsFromDiagnosticEvents returns unique contract IDs from diagnostic events (trace). -func collectContractIDsFromDiagnosticEvents(events []simulator.DiagnosticEvent) []string { - seen := make(map[string]struct{}) - var ids []string - for _, e := range events { - if e.ContractID != nil && *e.ContractID != "" { - if _, ok := seen[*e.ContractID]; !ok { - seen[*e.ContractID] = struct{}{} - ids = append(ids, *e.ContractID) - } - } - } - return ids -} - -func printSimulationResult(network string, res *simulator.SimulationResponse) { - fmt.Printf("\n--- Result for %s ---\n", network) - fmt.Printf("Status: %s\n", res.Status) - if res.Error != "" { - fmt.Printf("Error: %s\n", res.Error) - } - - // Display budget usage if available - if res.BudgetUsage != nil { - fmt.Printf("\nResource Usage:\n") - - // CPU usage with percentage and warning indicator - cpuIndicator := "" - if res.BudgetUsage.CPUUsagePercent >= 95.0 { - cpuIndicator = " [!] CRITICAL" - } else if res.BudgetUsage.CPUUsagePercent >= 80.0 { - cpuIndicator = " [!] WARNING" - } - fmt.Printf(" CPU Instructions: %d / %d (%.2f%%)%s\n", - res.BudgetUsage.CPUInstructions, - res.BudgetUsage.CPULimit, - res.BudgetUsage.CPUUsagePercent, - cpuIndicator) - - // Memory usage with percentage and warning indicator - memIndicator := "" - if res.BudgetUsage.MemoryUsagePercent >= 95.0 { - memIndicator = " [!] CRITICAL" - } else if res.BudgetUsage.MemoryUsagePercent >= 80.0 { - memIndicator = " [!] WARNING" - } - fmt.Printf(" Memory Bytes: %d / %d (%.2f%%)%s\n", - res.BudgetUsage.MemoryBytes, - res.BudgetUsage.MemoryLimit, - res.BudgetUsage.MemoryUsagePercent, - memIndicator) - - fmt.Printf(" Operations: %d\n", res.BudgetUsage.OperationsCount) - } - - // Display diagnostic events with details - if len(res.DiagnosticEvents) > 0 { - fmt.Printf("\nDiagnostic Events: %d\n", len(res.DiagnosticEvents)) - for i, event := range res.DiagnosticEvents { - if i < 10 { // Show first 10 events - fmt.Printf(" [%d] Type: %s", i+1, event.EventType) - if event.ContractID != nil { - fmt.Printf(", Contract: %s", *event.ContractID) - } - if deprecatedFn, ok := deprecatedHostFunctionInDiagnosticEvent(event); ok { - fmt.Printf(" %s %s", visualizer.Warning(), visualizer.Colorize("deprecated host fn: "+deprecatedFn, "yellow")) - } - fmt.Printf("\n") - if len(event.Topics) > 0 { - fmt.Printf(" Topics: %v\n", event.Topics) - } - if event.Data != "" && len(event.Data) < 100 { - fmt.Printf(" Data: %s\n", event.Data) - } - } - } - if len(res.DiagnosticEvents) > 10 { - fmt.Printf(" ... and %d more events\n", len(res.DiagnosticEvents)-10) - } - } else { - fmt.Printf("\nEvents: %d\n", len(res.Events)) - } - - // Display logs - if len(res.Logs) > 0 { - fmt.Printf("\nLogs: %d\n", len(res.Logs)) - for i, log := range res.Logs { - if i < 5 { // Show first 5 logs - fmt.Printf(" - %s\n", log) - } - } - if len(res.Logs) > 5 { - fmt.Printf(" ... and %d more logs\n", len(res.Logs)-5) - } - } - fmt.Printf("Events: %d, Logs: %d\n", len(res.Events), len(res.Logs)) -} - -func diffResults(res1, res2 *simulator.SimulationResponse, net1, net2 string) { - fmt.Printf("\n=== Comparison: %s vs %s ===\n", net1, net2) - - if res1.Status != res2.Status { - fmt.Printf("Status Mismatch: %s (%s) vs %s (%s)\n", res1.Status, net1, res2.Status, net2) - } else { - fmt.Printf("Status Match: %s\n", res1.Status) - } - - // Compare diagnostic events if available - if len(res1.DiagnosticEvents) > 0 && len(res2.DiagnosticEvents) > 0 { - if len(res1.DiagnosticEvents) != len(res2.DiagnosticEvents) { - fmt.Printf("[DIFF] Diagnostic events count mismatch: %d vs %d\n", - len(res1.DiagnosticEvents), len(res2.DiagnosticEvents)) - } - } else if len(res1.Events) != len(res2.Events) { - fmt.Printf("[DIFF] Events count mismatch: %d vs %d\n", len(res1.Events), len(res2.Events)) - } - - // Compare budget usage if available - if res1.BudgetUsage != nil && res2.BudgetUsage != nil { - if res1.BudgetUsage.CPUInstructions != res2.BudgetUsage.CPUInstructions { - fmt.Printf("[DIFF] CPU instructions: %d vs %d\n", - res1.BudgetUsage.CPUInstructions, res2.BudgetUsage.CPUInstructions) - } - if res1.BudgetUsage.MemoryBytes != res2.BudgetUsage.MemoryBytes { - fmt.Printf("[DIFF] Memory bytes: %d vs %d\n", - res1.BudgetUsage.MemoryBytes, res2.BudgetUsage.MemoryBytes) - } - } - - // Compare Events - fmt.Println("\nEvent Diff:") - maxEvents := len(res1.Events) - if len(res2.Events) > maxEvents { - maxEvents = len(res2.Events) - } - - for i := 0; i < maxEvents; i++ { - var ev1, ev2 string - if i < len(res1.Events) { - ev1 = res1.Events[i] - } else { - ev1 = "" - } - - if i < len(res2.Events) { - ev2 = res2.Events[i] - } else { - ev2 = "" - } - - if ev1 != ev2 { - fmt.Printf(" [%d] MISMATCH:\n", i) - fmt.Printf(" %s: %s\n", net1, ev1) - fmt.Printf(" %s: %s\n", net2, ev2) - } - } -} - -// collectVisibleSections returns the names of output sections that contained -// data during the last simulation run. -func collectVisibleSections(resp *simulator.SimulationResponse, findings []security.Finding, hasTokenFlows bool) []string { - var sections []string - if resp.BudgetUsage != nil { - sections = append(sections, "budget") - } - if len(resp.DiagnosticEvents) > 0 { - sections = append(sections, "events") - } - if len(resp.Logs) > 0 { - sections = append(sections, "logs") - } - if len(findings) > 0 { - sections = append(sections, "security") - } - if hasTokenFlows { - sections = append(sections, "tokenflow") - } - return sections -} - -func applySimulationFeeMocks(req *simulator.SimulationRequest) { - if req == nil { - return - } - - if mockBaseFeeFlag > 0 { - baseFee := mockBaseFeeFlag - req.MockBaseFee = &baseFee - } - if mockGasPriceFlag > 0 { - gasPrice := mockGasPriceFlag - req.MockGasPrice = &gasPrice - } -} - -var deprecatedSorobanHostFunctions = []string{ - "bytes_copy_from_linear_memory", - "bytes_copy_to_linear_memory", - "bytes_new_from_linear_memory", - "map_new_from_linear_memory", - "map_unpack_to_linear_memory", - "symbol_new_from_linear_memory", - "string_new_from_linear_memory", - "vec_new_from_linear_memory", - "vec_unpack_to_linear_memory", -} - -func deprecatedHostFunctionInDiagnosticEvent(event simulator.DiagnosticEvent) (string, bool) { - if name, ok := findDeprecatedHostFunction(strings.Join(event.Topics, " ")); ok { - return name, true - } - return findDeprecatedHostFunction(event.Data) -} - -func findDeprecatedHostFunction(input string) (string, bool) { - lower := strings.ToLower(input) - for _, fn := range deprecatedSorobanHostFunctions { - if strings.Contains(lower, strings.ToLower(fn)) { - return fn, true - } - } - return "", false -} - -func init() { - debugCmd.Flags().StringVarP(&networkFlag, "network", "n", "mainnet", "Stellar network (auto-detected when omitted; testnet, mainnet, futurenet)") - debugCmd.Flags().StringVar(&rpcURLFlag, "rpc-url", "", "Custom RPC URL") - debugCmd.Flags().StringVar(&rpcTokenFlag, "rpc-token", "", "RPC authentication token (can also use ERST_RPC_TOKEN env var)") - debugCmd.Flags().BoolVar(&tracingEnabled, "tracing", false, "Enable tracing") - debugCmd.Flags().StringVar(&otlpExporterURL, "otlp-url", "http://localhost:4318", "OTLP URL") - debugCmd.Flags().BoolVar(&generateTrace, "generate-trace", false, "Generate trace file") - debugCmd.Flags().StringVar(&traceOutputFile, "trace-output", "", "Trace output file") - debugCmd.Flags().StringVar(&snapshotFlag, "snapshot", "", "Load state from JSON snapshot file") - debugCmd.Flags().StringVar(&compareNetworkFlag, "compare-network", "", "Network to compare against (testnet, mainnet, futurenet)") - debugCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output") - debugCmd.Flags().StringVar(&wasmPath, "wasm", "", "Path to local WASM file for local replay (no network required)") - debugCmd.Flags().StringSliceVar(&args, "args", []string{}, "Mock arguments for local replay (JSON array of strings)") - debugCmd.Flags().BoolVar(&noCacheFlag, "no-cache", false, "Disable local ledger state caching") - debugCmd.Flags().BoolVar(&demoMode, "demo", false, "Print sample output (no network) - for testing color detection") - debugCmd.Flags().BoolVar(&watchFlag, "watch", false, "Poll for transaction on-chain before debugging") - debugCmd.Flags().IntVar(&watchTimeoutFlag, "watch-timeout", 30, "Timeout in seconds for watch mode") - debugCmd.Flags().BoolVar(&hotReloadFlag, "hot-reload", false, "Hot reload local WASM changes during debug session (requires --wasm)") - debugCmd.Flags().DurationVar(&hotReloadInterval, "hot-reload-interval", 500*time.Millisecond, "Polling interval fallback for hot reload (e.g. 500ms)") - debugCmd.Flags().Uint32Var(&mockBaseFeeFlag, "mock-base-fee", 0, "Override base fee (stroops) for local fee sufficiency checks") - debugCmd.Flags().Uint64Var(&mockGasPriceFlag, "mock-gas-price", 0, "Override gas price multiplier for local fee sufficiency checks") - debugCmd.Flags().StringVar(&themeFlag, "theme", "", "Color theme override (dark, light, none)") - debugCmd.Flags().Int64Var(&mockTimeFlag, "mock-time", 0, "Override ledger timestamp for simulation (Unix seconds)") - debugCmd.Flags().Uint32Var(&protocolVersionFlag, "protocol-version", 0, "Override protocol version for simulation") - - rootCmd.AddCommand(debugCmd) -} - -// checkLTOWarning searches the directory tree around a WASM file for -// Cargo.toml files with LTO settings and prints a warning if found. -// It searches the WASM file's parent directory and up to two levels up -// to find the project root. -func checkLTOWarning(wasmFilePath string) { - dir := filepath.Dir(wasmFilePath) - - // Walk up to 3 levels to find Cargo.toml files - for i := 0; i < 3; i++ { - results, err := lto.CheckProjectDir(dir) - if err != nil { - logger.Logger.Debug("LTO check failed", "dir", dir, "error", err) - break - } - if lto.HasLTO(results) { - fmt.Fprintf(os.Stderr, "\n%s\n", lto.FormatWarnings(results)) - return - } - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent - } -} -func displaySourceLocation(loc *simulator.SourceLocation) { - fmt.Printf("%s Location: %s:%d:%d\n", visualizer.Symbol("location"), loc.File, loc.Line, loc.Column) - - // Try to find the file - content, err := os.ReadFile(loc.File) - if err != nil { - // Try to find in current directory or src - if c, err := os.ReadFile(filepath.Join("src", loc.File)); err == nil { - content = c - } else { - return - } - } - - lines := strings.Split(string(content), "\n") - if int(loc.Line) > len(lines) { - return - } - - // Show context - start := int(loc.Line) - 3 - if start < 0 { - start = 0 - } - end := int(loc.Line) + 2 - if end > len(lines) { - end = len(lines) - } - - fmt.Println() - for i := start; i < end; i++ { - lineNum := i + 1 - prefix := " " - if lineNum == int(loc.Line) { - prefix = "> " - } - - fmt.Printf("%s %4d | %s\n", prefix, lineNum, lines[i]) - - // Highlight the token if this is the failing line - if lineNum == int(loc.Line) { - // Calculate exact indentation to line up with the printed line - // prefix (2) + lineNum (4) + pipe (3) = 9 spaces - markerIndent := strings.Repeat(" ", 9) - offset := int(loc.Column) - 1 - if offset < 0 { - offset = 0 - } - - highlightLen := 1 - if loc.ColumnEnd != nil && *loc.ColumnEnd > loc.Column { - highlightLen = int(*loc.ColumnEnd - loc.Column) - } - - // Don't exceed line length - if offset < len(lines[i]) { - if offset+highlightLen > len(lines[i]) { - highlightLen = len(lines[i]) - offset - } - marker := strings.Repeat(" ", offset) + strings.Repeat("^", highlightLen) - fmt.Printf(" | %s%s\n", markerIndent[:2], marker) - } - } - } - fmt.Println() -} +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/dotandev/hintents/internal/config" + "github.com/dotandev/hintents/internal/decenstorage" + "github.com/dotandev/hintents/internal/decoder" + "github.com/dotandev/hintents/internal/errors" + "github.com/dotandev/hintents/internal/logger" + "github.com/dotandev/hintents/internal/lto" + "github.com/dotandev/hintents/internal/rpc" + "github.com/dotandev/hintents/internal/security" + "github.com/dotandev/hintents/internal/session" + "github.com/dotandev/hintents/internal/simulator" + "github.com/dotandev/hintents/internal/snapshot" + "github.com/dotandev/hintents/internal/telemetry" + "github.com/dotandev/hintents/internal/tokenflow" + "github.com/dotandev/hintents/internal/visualizer" + "github.com/dotandev/hintents/internal/wat" + "github.com/dotandev/hintents/internal/watch" + + "github.com/spf13/cobra" + "github.com/stellar/go-stellar-sdk/xdr" + "go.opentelemetry.io/otel/attribute" +) + +var ( + networkFlag string + rpcURLFlag string + rpcTokenFlag string + tracingEnabled bool + otlpExporterURL string + generateTrace bool + traceOutputFile string + snapshotFlag string + compareNetworkFlag string + verbose bool + wasmPath string + wasmOptimizeFlag bool + args []string + themeFlag string + noCacheFlag bool + demoMode bool + watchFlag bool + watchTimeoutFlag int + hotReloadFlag bool + hotReloadInterval time.Duration + protocolVersionFlag uint32 + auditKeyFlag string + publishIPFSFlag bool + publishArweaveFlag bool + ipfsNodeFlag string + arweaveGatewayFlag string + arweaveWalletFlag string + mockTimeFlag int64 + mockBaseFeeFlag uint32 + mockGasPriceFlag uint64 + asyncFlag bool + asyncTimeoutFlag int +) + +// DebugCommand holds dependencies for the debug command +type DebugCommand struct { + Runner simulator.RunnerInterface +} + +// NewDebugCommand creates a new debug command with dependencies +func NewDebugCommand(runner simulator.RunnerInterface) *cobra.Command { + debugCmd := &DebugCommand{Runner: runner} + return debugCmd.createCommand() +} + +func (d *DebugCommand) createCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "debug ", + Short: "Debug a failed Soroban transaction", + Long: `Fetch a transaction envelope from the Stellar network and prepare it for simulation. + +Example: + erst debug 5c0a1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab + erst debug --network testnet `, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + // Validate network flag + switch rpc.Network(networkFlag) { + case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: + return nil + default: + return errors.WrapInvalidNetwork(networkFlag) + } + }, + RunE: d.runDebug, + } + + // Set up flags + cmd.Flags().StringVarP(&networkFlag, "network", "n", string(rpc.Mainnet), "Stellar network to use (testnet, mainnet, futurenet)") + cmd.Flags().StringVar(&rpcURLFlag, "rpc-url", "", "Custom Horizon RPC URL to use") + cmd.Flags().StringVar(&rpcTokenFlag, "rpc-token", "", "RPC authentication token (can also use ERST_RPC_TOKEN env var)") + + return cmd +} + +func (d *DebugCommand) runDebug(cmd *cobra.Command, args []string) error { + txHash := args[0] + + token := rpcTokenFlag + if token == "" { + token = os.Getenv("ERST_RPC_TOKEN") + } + if token == "" { + cfg, err := config.Load() + if err == nil && cfg.RPCToken != "" { + token = cfg.RPCToken + } + } + + opts := []rpc.ClientOption{ + rpc.WithNetwork(rpc.Network(networkFlag)), + rpc.WithToken(token), + } + if rpcURLFlag != "" { + opts = append(opts, rpc.WithHorizonURL(rpcURLFlag)) + } + + client, err := rpc.NewClient(opts...) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to create client: %v", err)) + } + + fmt.Printf("Debugging transaction: %s\n", txHash) + fmt.Printf("Network: %s\n", networkFlag) + if rpcURLFlag != "" { + fmt.Printf("RPC URL: %s\n", rpcURLFlag) + } + + // Fetch transaction details + resp, err := client.GetTransaction(cmd.Context(), txHash) + if err != nil { + return errors.WrapRPCConnectionFailed(err) + } + + fmt.Printf("Transaction fetched successfully. Envelope size: %d bytes\n", len(resp.EnvelopeXdr)) + + // TODO: Use d.Runner for simulation when ready + // simReq := &simulator.SimulationRequest{ + // EnvelopeXdr: resp.EnvelopeXdr, + // ResultMetaXdr: resp.ResultMetaXdr, + // } + // simResp, err := d.Runner.Run(simReq) + + return nil +} + +func NewDebugCmd() *cobra.Command { + debugCmd := &cobra.Command{ + Use: "debug ", + Short: "Debug a failed Soroban transaction", + Long: `Fetch and simulate a Soroban transaction to debug failures and analyze execution. + +This command retrieves the transaction envelope from the Stellar network, runs it +through the local simulator, and displays detailed execution traces including: + - Transaction status and error messages + - Contract events and diagnostic logs + - Token flows (XLM and Soroban assets) + - Execution metadata and state changes + +The simulation results are stored in a session that can be saved for later analysis. + +Local WASM Replay Mode: + Use --wasm flag to test contracts locally without network data.`, + Example: ` # Debug a transaction on mainnet + erst debug 5c0a1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab + + # Debug on testnet + erst debug --network testnet abc123...def789 + + # Debug and compare results between networks + erst debug --network mainnet --compare-network testnet abc123...def789 + + # Debug and save the session + erst debug abc123...def789 && erst session save + + # Compare execution across networks + erst debug --network testnet --compare-network mainnet + + # Local WASM replay (no network required) + erst debug --wasm ./contract.wasm --args "arg1" --args "arg2" + + # Demo mode (test color output, no network required) + erst debug --demo`, + Args: cobra.MaximumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + if hotReloadFlag && wasmPath == "" { + return errors.WrapValidationError("--hot-reload requires --wasm") + } + + // Demo mode or local WASM replay don't need transaction hash + if demoMode || wasmPath != "" { + return nil + } + + if len(args) == 0 { + return errors.WrapValidationError("transaction hash is required when not using --wasm or --demo flag") + } + + if err := rpc.ValidateTransactionHash(args[0]); err != nil { + return errors.WrapValidationError(fmt.Sprintf("invalid transaction hash format: %v", err)) + } + + if !cmd.Flags().Changed("network") { + token := rpcTokenFlag + if token == "" { + token = os.Getenv("ERST_RPC_TOKEN") + } + probeCtx, probeCancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer probeCancel() + if resolved, err := rpc.ResolveNetwork(probeCtx, args[0], token); err == nil { + networkFlag = string(resolved) + fmt.Printf("Resolved network: %s\n", networkFlag) + } + } + + // Validate network flag + switch rpc.Network(networkFlag) { + case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: + // valid + default: + return errors.WrapInvalidNetwork(networkFlag) + } + + // Validate compare network flag if present + if compareNetworkFlag != "" { + switch rpc.Network(compareNetworkFlag) { + case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: + // valid + default: + return errors.WrapInvalidNetwork(compareNetworkFlag) + } + } + return nil + }, + RunE: func(cmd *cobra.Command, cmdArgs []string) error { + if verbose { + logger.SetLevel(slog.LevelInfo) + } else { + logger.SetLevel(slog.LevelWarn) + } + + // Apply theme if specified, otherwise auto-detect + if themeFlag != "" { + visualizer.SetTheme(visualizer.Theme(themeFlag)) + } else { + visualizer.SetTheme(visualizer.DetectTheme()) + } + + // Demo mode: print sample output for testing color detection (no network) + if demoMode { + return runDemoMode(cmdArgs) + } + + // Local WASM replay mode + if wasmPath != "" { + return runLocalWasmReplay() + } + + // Network transaction replay mode + ctx := cmd.Context() + txHash := cmdArgs[0] + + // Load persisted viewer state for this transaction (best-effort). + var uiStore *session.UIStateStore + if s, err := session.NewUIStateStore(); err == nil { + uiStore = s + defer uiStore.Close() + if prev, err := uiStore.LoadSectionState(ctx, txHash); err == nil && len(prev) > 0 { + fmt.Printf("Restoring viewer state: last session showed [%s] for this transaction.\n", strings.Join(prev, ", ")) + } + } + + // Initialize OpenTelemetry if enabled + if tracingEnabled { + cleanup, err := telemetry.Init(ctx, telemetry.Config{ + Enabled: true, + ExporterURL: otlpExporterURL, + ServiceName: "erst", + }) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to initialize telemetry: %v", err)) + } + defer cleanup() + } + + // Start root span + tracer := telemetry.GetTracer() + ctx, span := tracer.Start(ctx, "debug_transaction") + span.SetAttributes( + attribute.String("transaction.hash", txHash), + attribute.String("network", networkFlag), + ) + defer span.End() + + var horizonURL string + token := rpcTokenFlag + if token == "" { + token = os.Getenv("ERST_RPC_TOKEN") + } + if token == "" { + if cfg, err := config.Load(); err == nil && cfg.RPCToken != "" { + token = cfg.RPCToken + } + } + + opts := []rpc.ClientOption{ + rpc.WithNetwork(rpc.Network(networkFlag)), + rpc.WithToken(token), + } + + if rpcURLFlag != "" { + urls := strings.Split(rpcURLFlag, ",") + for i := range urls { + urls[i] = strings.TrimSpace(urls[i]) + } + opts = append(opts, rpc.WithAltURLs(urls)) + horizonURL = urls[0] + } else { + cfg, err := config.Load() + if err == nil { + if len(cfg.RpcUrls) > 0 { + opts = append(opts, rpc.WithAltURLs(cfg.RpcUrls)) + horizonURL = cfg.RpcUrls[0] + } else if cfg.RpcUrl != "" { + opts = append(opts, rpc.WithHorizonURL(cfg.RpcUrl)) + horizonURL = cfg.RpcUrl + } + } + } + + client, err := rpc.NewClient(opts...) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to create client: %v", err)) + } + + if horizonURL == "" { + // Extract horizon URL from valid client if not explicitly set + horizonURL = client.HorizonURL + } + + if noCacheFlag { + client.CacheEnabled = false + fmt.Println("🚫 Cache disabled by --no-cache flag") + } + + fmt.Printf("Debugging transaction: %s\n", txHash) + fmt.Printf("Primary Network: %s\n", networkFlag) + if compareNetworkFlag != "" { + fmt.Printf("Comparing against Network: %s\n", compareNetworkFlag) + } + + // Fetch transaction details + if watchFlag { + spinner := watch.NewSpinner() + poller := watch.NewPoller(watch.PollerConfig{ + InitialInterval: 1 * time.Second, + MaxInterval: 10 * time.Second, + TimeoutDuration: time.Duration(watchTimeoutFlag) * time.Second, + }) + + spinner.Start("Waiting for transaction to appear on-chain...") + + result, err := poller.Poll(ctx, func(pollCtx context.Context) (interface{}, error) { + _, pollErr := client.GetTransaction(pollCtx, txHash) + if pollErr != nil { + return nil, pollErr + } + return true, nil + }, nil) + + if err != nil { + spinner.StopWithError("Failed to poll for transaction") + return errors.WrapSimulationLogicError(fmt.Sprintf("watch mode error: %v", err)) + } + + if !result.Found { + spinner.StopWithError("Transaction not found within timeout") + return errors.WrapTransactionNotFound(fmt.Errorf("not found after %d seconds", watchTimeoutFlag)) + } + + spinner.StopWithMessage("Transaction found! Starting debug...") + } + + fmt.Printf("Fetching transaction: %s\n", txHash) + resp, err := client.GetTransaction(ctx, txHash) + if err != nil { + return errors.WrapRPCConnectionFailed(err) + } + + fmt.Printf("Transaction fetched successfully. Envelope size: %d bytes\n", len(resp.EnvelopeXdr)) + + // Extract ledger keys for replay + keys, err := extractLedgerKeys(resp.ResultMetaXdr) + if err != nil { + return errors.WrapUnmarshalFailed(err, "result meta") + } + + // Initialize Simulator Runner + runner, err := simulator.NewRunnerWithMockTime("", tracingEnabled, mockTimeFlag) + if err != nil { + return errors.WrapSimulatorNotFound(err.Error()) + } + + // Determine timestamps to simulate + timestamps := []int64{TimestampFlag} + if WindowFlag > 0 && TimestampFlag > 0 { + // Simulate 5 steps across the window + step := WindowFlag / 4 + for i := 1; i <= 4; i++ { + timestamps = append(timestamps, TimestampFlag+int64(i)*step) + } + } + + var lastSimResp *simulator.SimulationResponse + + for _, ts := range timestamps { + if len(timestamps) > 1 { + fmt.Printf("\n--- Simulating at Timestamp: %d ---\n", ts) + } + + var simResp *simulator.SimulationResponse + var ledgerEntries map[string]string + + if compareNetworkFlag == "" { + // Single Network Run + if snapshotFlag != "" { + snap, err := snapshot.Load(snapshotFlag) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to load snapshot: %v", err)) + } + ledgerEntries = snap.ToMap() + fmt.Printf("Loaded %d ledger entries from snapshot\n", len(ledgerEntries)) + } else { + // Try to extract from metadata first, fall back to fetching + ledgerEntries, err = rpc.ExtractLedgerEntriesFromMeta(resp.ResultMetaXdr) + if err != nil { + logger.Logger.Warn("Failed to extract ledger entries from metadata, fetching from network", "error", err) + ledgerEntries, err = client.GetLedgerEntries(ctx, keys) + if err != nil { + return errors.WrapRPCConnectionFailed(err) + } + } else { + logger.Logger.Info("Extracted ledger entries for simulation", "count", len(ledgerEntries)) + } + } + + fmt.Printf("Running simulation on %s...\n", networkFlag) + simReq := &simulator.SimulationRequest{ + EnvelopeXdr: resp.EnvelopeXdr, + ResultMetaXdr: resp.ResultMetaXdr, + LedgerEntries: ledgerEntries, + Timestamp: ts, + ProtocolVersion: nil, + } + + // Apply protocol version override if specified + if protocolVersionFlag > 0 { + if err := simulator.Validate(protocolVersionFlag); err != nil { + return fmt.Errorf("invalid protocol version %d: %w", protocolVersionFlag, err) + } + simReq.ProtocolVersion = &protocolVersionFlag + fmt.Printf("Using protocol version override: %d\n", protocolVersionFlag) + } + applySimulationFeeMocks(simReq) + + simResp, err = runner.Run(ctx, simReq) + if err != nil { + return errors.WrapSimulationFailed(err, "") + } + printSimulationResult(networkFlag, simResp) + // Fetch contract bytecode on demand for any contract calls in the trace; cache via RPC client + if client != nil && simResp != nil && len(simResp.DiagnosticEvents) > 0 { + contractIDs := collectContractIDsFromDiagnosticEvents(simResp.DiagnosticEvents) + if len(contractIDs) > 0 { + _, _ = rpc.FetchBytecodeForTraceContractCalls(ctx, client, contractIDs, nil) + } + } + } else { + // Comparison Run + var wg sync.WaitGroup + var primaryResult, compareResult *simulator.SimulationResponse + var primaryErr, compareErr error + + wg.Add(2) + go func() { + defer wg.Done() + var entries map[string]string + var extractErr error + entries, extractErr = rpc.ExtractLedgerEntriesFromMeta(resp.ResultMetaXdr) + if extractErr != nil { + entries, extractErr = client.GetLedgerEntries(ctx, keys) + if extractErr != nil { + primaryErr = extractErr + return + } + } + primaryReq := &simulator.SimulationRequest{ + EnvelopeXdr: resp.EnvelopeXdr, + ResultMetaXdr: resp.ResultMetaXdr, + LedgerEntries: entries, + Timestamp: ts, + } + applySimulationFeeMocks(primaryReq) + primaryResult, primaryErr = runner.Run(ctx, primaryReq) + }() + + go func() { + defer wg.Done() + compareOpts := []rpc.ClientOption{ + rpc.WithNetwork(rpc.Network(compareNetworkFlag)), + rpc.WithToken(rpcTokenFlag), + } + compareClient, clientErr := rpc.NewClient(compareOpts...) + if clientErr != nil { + compareErr = errors.WrapValidationError(fmt.Sprintf("failed to create compare client: %v", clientErr)) + return + } + if noCacheFlag { + compareClient.CacheEnabled = false + } + + compareResp, txErr := compareClient.GetTransaction(ctx, txHash) + if txErr != nil { + compareErr = errors.WrapRPCConnectionFailed(txErr) + return + } + + entries, extractErr := rpc.ExtractLedgerEntriesFromMeta(compareResp.ResultMetaXdr) + if extractErr != nil { + entries, extractErr = compareClient.GetLedgerEntries(ctx, keys) + if extractErr != nil { + compareErr = extractErr + return + } + } + + compareReq := &simulator.SimulationRequest{ + EnvelopeXdr: resp.EnvelopeXdr, + ResultMetaXdr: compareResp.ResultMetaXdr, + LedgerEntries: entries, + Timestamp: ts, + } + applySimulationFeeMocks(compareReq) + compareResult, compareErr = runner.Run(ctx, compareReq) + }() + + wg.Wait() + if primaryErr != nil { + return errors.WrapRPCConnectionFailed(primaryErr) + } + if compareErr != nil { + return errors.WrapRPCConnectionFailed(compareErr) + } + // Fetch contract bytecode on demand for contract calls in the trace; cache via RPC client + if client != nil && primaryResult != nil && len(primaryResult.DiagnosticEvents) > 0 { + contractIDs := collectContractIDsFromDiagnosticEvents(primaryResult.DiagnosticEvents) + if len(contractIDs) > 0 { + _, _ = rpc.FetchBytecodeForTraceContractCalls(ctx, client, contractIDs, nil) + } + } + + simResp = primaryResult // Use primary for further analysis + printSimulationResult(networkFlag, primaryResult) + printSimulationResult(compareNetworkFlag, compareResult) + diffResults(primaryResult, compareResult, networkFlag, compareNetworkFlag) + } + lastSimResp = simResp + } + + if lastSimResp == nil { + return errors.WrapSimulationLogicError("no simulation results generated") + } + + // Analysis: Error Suggestions (Heuristic-based) + if len(lastSimResp.Events) > 0 { + suggestionEngine := decoder.NewSuggestionEngine() + + // Load config to get MaxTraceDepth + cfg, _ := config.Load() + maxDepth := 50 + if cfg != nil { + maxDepth = cfg.MaxTraceDepth + } + + // Decode events for analysis + callTree, err := decoder.DecodeEvents(lastSimResp.Events, maxDepth) + if err == nil && callTree != nil { + suggestions := suggestionEngine.AnalyzeCallTree(callTree) + if len(suggestions) > 0 { + fmt.Print(decoder.FormatSuggestions(suggestions)) + } + } + } + + // Analysis: Security + fmt.Printf("\n=== Security Analysis ===\n") + secDetector := security.NewDetector() + findings := secDetector.Analyze(resp.EnvelopeXdr, resp.ResultMetaXdr, lastSimResp.Events, lastSimResp.Logs) + if len(findings) == 0 { + fmt.Printf("%s No security issues detected\n", visualizer.Success()) + } else { + verifiedCount := 0 + heuristicCount := 0 + + for _, finding := range findings { + if finding.Type == security.FindingVerifiedRisk { + verifiedCount++ + } else { + heuristicCount++ + } + } + + if verifiedCount > 0 { + fmt.Printf("\n[!] VERIFIED SECURITY RISKS: %d\n", verifiedCount) + } + if heuristicCount > 0 { + fmt.Printf("* HEURISTIC WARNINGS: %d\n", heuristicCount) + } + + fmt.Printf("\nFindings:\n") + for i, finding := range findings { + icon := "*" + if finding.Type == security.FindingVerifiedRisk { + icon = "[!]" + } + fmt.Printf("%d. %s [%s] %s - %s\n", i+1, icon, finding.Type, finding.Severity, finding.Title) + fmt.Printf(" %s\n", finding.Description) + if finding.Evidence != "" { + fmt.Printf(" Evidence: %s\n", finding.Evidence) + } + } + } + + // Analysis: Token Flows + hasTokenFlows := false + if report, err := tokenflow.BuildReport(resp.EnvelopeXdr, resp.ResultMetaXdr); err == nil && len(report.Agg) > 0 { + hasTokenFlows = true + fmt.Printf("\nToken Flow Summary:\n") + for _, line := range report.SummaryLines() { + fmt.Printf(" %s\n", line) + } + fmt.Printf("\nToken Flow Chart (Mermaid):\n") + fmt.Println(report.MermaidFlowchart()) + } + + // Persist viewer state so the next debug of this transaction restores context. + if uiStore != nil { + _ = uiStore.SaveSectionState(ctx, txHash, collectVisibleSections(lastSimResp, findings, hasTokenFlows)) + } + + // Session Management + simReq := &simulator.SimulationRequest{ + EnvelopeXdr: resp.EnvelopeXdr, + ResultMetaXdr: resp.ResultMetaXdr, + } + applySimulationFeeMocks(simReq) + simReqJSON, err := json.Marshal(simReq) + if err != nil { + fmt.Printf("Warning: failed to serialize simulation data: %v\n", err) + } + simRespJSON, err := json.Marshal(lastSimResp) + if err != nil { + fmt.Printf("Warning: failed to serialize simulation results: %v\n", err) + } + + sessionData := &session.SessionData{ + ID: session.GenerateID(txHash), + CreatedAt: time.Now(), + LastAccessAt: time.Now(), + Status: "active", + Network: networkFlag, + HorizonURL: horizonURL, + TxHash: txHash, + EnvelopeXdr: resp.EnvelopeXdr, + ResultXdr: resp.ResultXdr, + ResultMetaXdr: resp.ResultMetaXdr, + SimRequestJSON: string(simReqJSON), + SimResponseJSON: string(simRespJSON), + ErstVersion: Version, + SchemaVersion: session.SchemaVersion, + } + SetCurrentSession(sessionData) + fmt.Printf("\nSession created: %s\n", sessionData.ID) + fmt.Printf("Run 'erst session save' to persist this session.\n") + + // Publish signed audit trail to decentralised storage when requested. + if publishIPFSFlag || publishArweaveFlag { + if auditKeyFlag == "" { + return errors.WrapCliArgumentRequired("audit-key") + } + auditLog, auditErr := Generate( + txHash, + resp.EnvelopeXdr, + resp.ResultMetaXdr, + lastSimResp.Events, + lastSimResp.Logs, + auditKeyFlag, + nil, + ) + if auditErr != nil { + return fmt.Errorf("failed to generate audit log: %w", auditErr) + } + auditBytes, auditErr := json.Marshal(auditLog) + if auditErr != nil { + return fmt.Errorf("failed to marshal audit log: %w", auditErr) + } + + pub := decenstorage.New(decenstorage.PublishConfig{ + IPFSNode: ipfsNodeFlag, + ArweaveGateway: arweaveGatewayFlag, + ArweaveWallet: arweaveWalletFlag, + }) + + fmt.Printf("\n=== Decentralised Storage ===\n") + + if publishIPFSFlag { + result, ipfsErr := pub.PublishIPFS(ctx, auditBytes) + if ipfsErr != nil { + fmt.Printf("IPFS publish failed: %v\n", ipfsErr) + } else { + fmt.Printf("IPFS CID : %s\n", result.CID) + fmt.Printf("IPFS URL : %s\n", result.URL) + } + } + + if publishArweaveFlag { + result, arErr := pub.PublishArweave(ctx, auditBytes) + if arErr != nil { + fmt.Printf("Arweave publish failed: %v\n", arErr) + } else { + fmt.Printf("Arweave TXID : %s\n", result.TXID) + fmt.Printf("Arweave URL : %s\n", result.URL) + } + } + } + + return nil + }, + } + debugCmd.Flags().StringVarP(&networkFlag, "network", "n", "mainnet", "Stellar network (auto-detected when omitted; testnet, mainnet, futurenet)") + debugCmd.Flags().StringVar(&rpcURLFlag, "rpc-url", "", "Custom RPC URL") + debugCmd.Flags().StringVar(&rpcTokenFlag, "rpc-token", "", "RPC authentication token (can also use ERST_RPC_TOKEN env var)") + debugCmd.Flags().BoolVar(&tracingEnabled, "tracing", false, "Enable tracing") + debugCmd.Flags().StringVar(&otlpExporterURL, "otlp-url", "http://localhost:4318", "OTLP URL") + debugCmd.Flags().BoolVar(&generateTrace, "generate-trace", false, "Generate trace file") + debugCmd.Flags().StringVar(&traceOutputFile, "trace-output", "", "Trace output file") + debugCmd.Flags().StringVar(&snapshotFlag, "snapshot", "", "Load state from JSON snapshot file") + debugCmd.Flags().StringVar(&compareNetworkFlag, "compare-network", "", "Network to compare against (testnet, mainnet, futurenet)") + debugCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output") + debugCmd.Flags().StringVar(&wasmPath, "wasm", "", "Path to local WASM file for local replay (no network required)") + debugCmd.Flags().StringSliceVar(&args, "args", []string{}, "Mock arguments for local replay (JSON array of strings)") + debugCmd.Flags().BoolVar(&noCacheFlag, "no-cache", false, "Disable local ledger state caching") + debugCmd.Flags().BoolVar(&demoMode, "demo", false, "Print sample output (no network) - for testing color detection") + debugCmd.Flags().BoolVar(&watchFlag, "watch", false, "Poll for transaction on-chain before debugging") + debugCmd.Flags().IntVar(&watchTimeoutFlag, "watch-timeout", 30, "Timeout in seconds for watch mode") + debugCmd.Flags().Uint32Var(&mockBaseFeeFlag, "mock-base-fee", 0, "Override base fee (stroops) for local fee sufficiency checks") + debugCmd.Flags().Uint64Var(&mockGasPriceFlag, "mock-gas-price", 0, "Override gas price multiplier for local fee sufficiency checks") + debugCmd.Flags().StringVar(&themeFlag, "theme", "", "Color theme override (dark, light, none)") + debugCmd.Flags().Int64Var(&mockTimeFlag, "mock-time", 0, "Override ledger timestamp for simulation (Unix seconds)") + debugCmd.Flags().Uint32Var(&protocolVersionFlag, "protocol-version", 0, "Override protocol version for simulation") + debugCmd.Flags().BoolVar(&hotReloadFlag, "hot-reload", false, "Hot reload local WASM changes during debug session (requires --wasm)") + debugCmd.Flags().DurationVar(&hotReloadInterval, "hot-reload-interval", 500*time.Millisecond, "Polling interval fallback for hot reload (e.g. 500ms)") + return debugCmd +} + +// runDemoMode prints sample output without network/WASM - for testing color detection. +func runDemoMode(cmdArgs []string) error { + txHash := "5c0a1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab" + if len(cmdArgs) > 0 && len(cmdArgs[0]) == 64 { + txHash = cmdArgs[0] + } + + fmt.Printf("Fetching transaction: %s\n", txHash) + fmt.Printf("Transaction fetched successfully. Envelope size: 256 bytes\n") + fmt.Printf("\n--- Result for %s ---\n", networkFlag) + fmt.Printf("Status: success\n") + fmt.Printf("\nResource Usage:\n") + fmt.Printf(" CPU Instructions: 12345\n") + fmt.Printf(" Memory Bytes: 1024\n") + fmt.Printf(" Operations: 5\n") + fmt.Printf("\nEvents: 2, Logs: 3\n") + fmt.Printf("\n=== Security Analysis ===\n") + fmt.Printf("%s No security issues detected\n", visualizer.Success()) + fmt.Printf("\nToken Flow Summary:\n") + fmt.Printf(" %s XLM transferred\n", visualizer.Symbol("arrow_r")) + fmt.Printf("\nSession ready. Use 'erst session save' to persist.\n") + return nil +} + +func runLocalWasmReplay() error { + fmt.Printf("%s WARNING: Using Mock State (not mainnet data)\n", visualizer.Warning()) + fmt.Println() + + // Verify WASM file exists + if _, err := os.Stat(wasmPath); os.IsNotExist(err) { + return errors.WrapValidationError(fmt.Sprintf("WASM file not found: %s", wasmPath)) + } + + fmt.Printf("%s Local WASM Replay Mode\n", visualizer.Symbol("wrench")) + fmt.Printf("WASM File: %s\n", wasmPath) + fmt.Printf("Arguments: %v\n", args) + fmt.Println() + + // Check for LTO in the project that produced the WASM + checkLTOWarning(wasmPath) + + // Create simulator runner + runner, err := simulator.NewRunner("", tracingEnabled) + if err != nil { + return errors.WrapSimulatorNotFound(err.Error()) + } + defer runner.Close() + + ctx := context.Background() + if hotReloadFlag { + return runLocalWasmReplaySession(ctx, runner, os.Stdin, os.Stdout) + } + return runLocalWasmReplayOnce(ctx, runner, false) +} + +func newLocalWasmSimulationRequest(forceNoCache bool) *simulator.SimulationRequest { + req := &simulator.SimulationRequest{ + EnvelopeXdr: "", // Empty for local replay + ResultMetaXdr: "", // Empty for local replay + LedgerEntries: nil, // Mock state will be generated + WasmPath: &wasmPath, + NoCache: noCacheFlag || forceNoCache, + MockArgs: &args, + } + applySimulationFeeMocks(req) + return req +} + +func runLocalWasmReplayOnce(ctx context.Context, runner simulator.RunnerInterface, forceNoCache bool) error { + req := newLocalWasmSimulationRequest(forceNoCache) + + // Run simulation + fmt.Printf("%s Executing contract locally...\n", visualizer.Symbol("play")) + resp, err := runner.Run(ctx, req) + if err != nil { + fmt.Printf("%s Technical failure: %v\n", visualizer.Error(), err) + return err + } + + // Display results + fmt.Println() + if resp.Status == "error" { + fmt.Printf("%s Execution failed\n", visualizer.Error()) + if resp.Error != "" { + fmt.Printf("Error: %s\n", resp.Error) + } + + // Fallback to WAT disassembly if source mapping is unavailable but we have an offset + if resp.SourceLocation == "" && resp.WasmOffset != nil { + fmt.Println() + wasmBytes, err := os.ReadFile(wasmPath) + if err == nil { + fallbackMsg := wat.FormatFallback(wasmBytes, *resp.WasmOffset, 5) + fmt.Println(fallbackMsg) + } + } + } else { + fmt.Printf("%s Execution completed successfully\n", visualizer.Success()) + } + fmt.Println() + + if len(resp.Logs) > 0 { + fmt.Printf("%s Logs:\n", visualizer.Symbol("logs")) + for _, log := range resp.Logs { + fmt.Printf(" %s\n", log) + } + fmt.Println() + } + + if len(resp.Events) > 0 { + fmt.Printf("%s Events:\n", visualizer.Symbol("events")) + for _, event := range resp.Events { + if deprecatedFn, ok := findDeprecatedHostFunction(event); ok { + fmt.Printf(" %s %s %s\n", event, visualizer.Warning(), visualizer.Colorize("deprecated host fn: "+deprecatedFn, "yellow")) + continue + } + fmt.Printf(" %s\n", event) + } + fmt.Println() + } + + if verbose { + fmt.Printf("%s Full Response:\n", visualizer.Symbol("magnify")) + jsonBytes, _ := json.MarshalIndent(resp, "", " ") + fmt.Println(string(jsonBytes)) + } + + return nil +} + +func runLocalWasmReplaySession(ctx context.Context, runner simulator.RunnerInterface, in io.Reader, out io.Writer) error { + fmt.Println("[watcher] Hot reload enabled") + if err := runLocalWasmReplayOnce(ctx, runner, false); err != nil { + return err + } + + initialFP, err := watch.ComputeWasmFingerprint(wasmPath, 5, 50*time.Millisecond) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to fingerprint initial wasm: %v", err)) + } + lastAppliedHash := initialFP.Hash + + cfg := watch.DefaultWasmReloaderConfig(wasmPath, hotReloadInterval) + reloadEvents, reloadErrors, err := watch.StartWasmReloader(ctx, cfg) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to start wasm watcher: %v", err)) + } + fmt.Println("[watcher] Watching for WASM changes") + + reader := bufio.NewReader(in) + var pending *watch.ReloadEvent + + for { + if pending != nil { + choice, promptErr := promptHotReloadChoice(reader, out) + if promptErr != nil { + return promptErr + } + + switch choice { + case 'r': + fmt.Println("[watcher] Re-running simulation with updated WASM") + if err := runLocalWasmReplayOnce(ctx, runner, true); err != nil { + fmt.Printf("[watcher] Re-run failed: %v\n", err) + } else { + lastAppliedHash = pending.Hash + } + case 's': + fmt.Println("[watcher] Reload skipped") + case 'q': + fmt.Println("[watcher] Exiting hot reload session") + return nil + } + pending = drainLatestReloadEvent(reloadEvents, lastAppliedHash) + continue + } + + select { + case <-ctx.Done(): + return nil + case watchErr, ok := <-reloadErrors: + if !ok { + reloadErrors = nil + continue + } + fmt.Printf("[watcher] Warning: %v\n", watchErr) + case event, ok := <-reloadEvents: + if !ok { + return nil + } + if event.Hash == lastAppliedHash { + continue + } + fmt.Println("[watcher] WASM updated (hash changed)") + fmt.Println("[watcher] Reload available") + pending = &event + } + } +} + +func promptHotReloadChoice(reader *bufio.Reader, out io.Writer) (byte, error) { + for { + fmt.Fprint(out, "Re-run simulation? (r = reload, s = skip, q = quit): ") + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF && strings.TrimSpace(line) != "" { + // Accept final line without trailing newline. + } else { + return 0, err + } + } + + choice := strings.ToLower(strings.TrimSpace(line)) + switch choice { + case "r", "s", "q": + return choice[0], nil + default: + fmt.Fprintln(out, "Invalid choice. Please enter r, s, or q.") + } + } +} + +func drainLatestReloadEvent(events <-chan watch.ReloadEvent, lastAppliedHash string) *watch.ReloadEvent { + var latest *watch.ReloadEvent + for { + select { + case event, ok := <-events: + if !ok { + return latest + } + if event.Hash == lastAppliedHash { + continue + } + ev := event + latest = &ev + default: + return latest + } + } +} + +func extractLedgerKeys(metaXdr string) ([]string, error) { + data, err := base64.StdEncoding.DecodeString(metaXdr) + if err != nil { + return nil, err + } + + var meta xdr.TransactionResultMeta + if err := xdr.SafeUnmarshal(data, &meta); err != nil { + return nil, err + } + + keysMap := make(map[string]struct{}) + addKey := func(k xdr.LedgerKey) { + b, _ := k.MarshalBinary() + keysMap[base64.StdEncoding.EncodeToString(b)] = struct{}{} + } + + collectChanges := func(changes xdr.LedgerEntryChanges) { + for _, c := range changes { + switch c.Type { + case xdr.LedgerEntryChangeTypeLedgerEntryCreated: + k, err := c.Created.LedgerKey() + if err == nil { + addKey(k) + } + case xdr.LedgerEntryChangeTypeLedgerEntryUpdated: + k, err := c.Updated.LedgerKey() + if err == nil { + addKey(k) + } + case xdr.LedgerEntryChangeTypeLedgerEntryRemoved: + if c.Removed != nil { + addKey(*c.Removed) + } + case xdr.LedgerEntryChangeTypeLedgerEntryState: + k, err := c.State.LedgerKey() + if err == nil { + addKey(k) + } + } + } + } + + // 1. Fee processing changes + collectChanges(meta.FeeProcessing) + + // 2. Transaction apply processing changes + switch meta.TxApplyProcessing.V { + case 0: + if meta.TxApplyProcessing.Operations != nil { + for _, op := range *meta.TxApplyProcessing.Operations { + collectChanges(op.Changes) + } + } + case 1: + if v1 := meta.TxApplyProcessing.V1; v1 != nil { + collectChanges(v1.TxChanges) + for _, op := range v1.Operations { + collectChanges(op.Changes) + } + } + case 2: + if v2 := meta.TxApplyProcessing.V2; v2 != nil { + collectChanges(v2.TxChangesBefore) + collectChanges(v2.TxChangesAfter) + for _, op := range v2.Operations { + collectChanges(op.Changes) + } + } + case 3: + if v3 := meta.TxApplyProcessing.V3; v3 != nil { + collectChanges(v3.TxChangesBefore) + collectChanges(v3.TxChangesAfter) + for _, op := range v3.Operations { + collectChanges(op.Changes) + } + } + } + + res := make([]string, 0, len(keysMap)) + for k := range keysMap { + res = append(res, k) + } + return res, nil +} + +// collectContractIDsFromDiagnosticEvents returns unique contract IDs from diagnostic events (trace). +func collectContractIDsFromDiagnosticEvents(events []simulator.DiagnosticEvent) []string { + seen := make(map[string]struct{}) + var ids []string + for _, e := range events { + if e.ContractID != nil && *e.ContractID != "" { + if _, ok := seen[*e.ContractID]; !ok { + seen[*e.ContractID] = struct{}{} + ids = append(ids, *e.ContractID) + } + } + } + return ids +} + +func printSimulationResult(network string, res *simulator.SimulationResponse) { + fmt.Printf("\n--- Result for %s ---\n", network) + fmt.Printf("Status: %s\n", res.Status) + if res.Error != "" { + fmt.Printf("Error: %s\n", res.Error) + } + + // Display budget usage if available + if res.BudgetUsage != nil { + fmt.Printf("\nResource Usage:\n") + + // CPU usage with percentage and warning indicator + cpuIndicator := "" + if res.BudgetUsage.CPUUsagePercent >= 95.0 { + cpuIndicator = " [!] CRITICAL" + } else if res.BudgetUsage.CPUUsagePercent >= 80.0 { + cpuIndicator = " [!] WARNING" + } + fmt.Printf(" CPU Instructions: %d / %d (%.2f%%)%s\n", + res.BudgetUsage.CPUInstructions, + res.BudgetUsage.CPULimit, + res.BudgetUsage.CPUUsagePercent, + cpuIndicator) + + // Memory usage with percentage and warning indicator + memIndicator := "" + if res.BudgetUsage.MemoryUsagePercent >= 95.0 { + memIndicator = " [!] CRITICAL" + } else if res.BudgetUsage.MemoryUsagePercent >= 80.0 { + memIndicator = " [!] WARNING" + } + fmt.Printf(" Memory Bytes: %d / %d (%.2f%%)%s\n", + res.BudgetUsage.MemoryBytes, + res.BudgetUsage.MemoryLimit, + res.BudgetUsage.MemoryUsagePercent, + memIndicator) + + fmt.Printf(" Operations: %d\n", res.BudgetUsage.OperationsCount) + } + + // Display diagnostic events with details + if len(res.DiagnosticEvents) > 0 { + fmt.Printf("\nDiagnostic Events: %d\n", len(res.DiagnosticEvents)) + for i, event := range res.DiagnosticEvents { + if i < 10 { // Show first 10 events + fmt.Printf(" [%d] Type: %s", i+1, event.EventType) + if event.ContractID != nil { + fmt.Printf(", Contract: %s", *event.ContractID) + } + if deprecatedFn, ok := deprecatedHostFunctionInDiagnosticEvent(event); ok { + fmt.Printf(" %s %s", visualizer.Warning(), visualizer.Colorize("deprecated host fn: "+deprecatedFn, "yellow")) + } + fmt.Printf("\n") + if len(event.Topics) > 0 { + fmt.Printf(" Topics: %v\n", event.Topics) + } + if event.Data != "" && len(event.Data) < 100 { + fmt.Printf(" Data: %s\n", event.Data) + } + } + } + if len(res.DiagnosticEvents) > 10 { + fmt.Printf(" ... and %d more events\n", len(res.DiagnosticEvents)-10) + } + } else { + fmt.Printf("\nEvents: %d\n", len(res.Events)) + } + + // Display logs + if len(res.Logs) > 0 { + fmt.Printf("\nLogs: %d\n", len(res.Logs)) + for i, log := range res.Logs { + if i < 5 { // Show first 5 logs + fmt.Printf(" - %s\n", log) + } + } + if len(res.Logs) > 5 { + fmt.Printf(" ... and %d more logs\n", len(res.Logs)-5) + } + } + fmt.Printf("Events: %d, Logs: %d\n", len(res.Events), len(res.Logs)) +} + +func diffResults(res1, res2 *simulator.SimulationResponse, net1, net2 string) { + fmt.Printf("\n=== Comparison: %s vs %s ===\n", net1, net2) + + if res1.Status != res2.Status { + fmt.Printf("Status Mismatch: %s (%s) vs %s (%s)\n", res1.Status, net1, res2.Status, net2) + } else { + fmt.Printf("Status Match: %s\n", res1.Status) + } + + // Compare diagnostic events if available + if len(res1.DiagnosticEvents) > 0 && len(res2.DiagnosticEvents) > 0 { + if len(res1.DiagnosticEvents) != len(res2.DiagnosticEvents) { + fmt.Printf("[DIFF] Diagnostic events count mismatch: %d vs %d\n", + len(res1.DiagnosticEvents), len(res2.DiagnosticEvents)) + } + } else if len(res1.Events) != len(res2.Events) { + fmt.Printf("[DIFF] Events count mismatch: %d vs %d\n", len(res1.Events), len(res2.Events)) + } + + // Compare budget usage if available + if res1.BudgetUsage != nil && res2.BudgetUsage != nil { + if res1.BudgetUsage.CPUInstructions != res2.BudgetUsage.CPUInstructions { + fmt.Printf("[DIFF] CPU instructions: %d vs %d\n", + res1.BudgetUsage.CPUInstructions, res2.BudgetUsage.CPUInstructions) + } + if res1.BudgetUsage.MemoryBytes != res2.BudgetUsage.MemoryBytes { + fmt.Printf("[DIFF] Memory bytes: %d vs %d\n", + res1.BudgetUsage.MemoryBytes, res2.BudgetUsage.MemoryBytes) + } + } + + // Compare Events + fmt.Println("\nEvent Diff:") + maxEvents := len(res1.Events) + if len(res2.Events) > maxEvents { + maxEvents = len(res2.Events) + } + + for i := 0; i < maxEvents; i++ { + var ev1, ev2 string + if i < len(res1.Events) { + ev1 = res1.Events[i] + } else { + ev1 = "" + } + + if i < len(res2.Events) { + ev2 = res2.Events[i] + } else { + ev2 = "" + } + + if ev1 != ev2 { + fmt.Printf(" [%d] MISMATCH:\n", i) + fmt.Printf(" %s: %s\n", net1, ev1) + fmt.Printf(" %s: %s\n", net2, ev2) + } + } +} + +// collectVisibleSections returns the names of output sections that contained +// data during the last simulation run. +func collectVisibleSections(resp *simulator.SimulationResponse, findings []security.Finding, hasTokenFlows bool) []string { + var sections []string + if resp.BudgetUsage != nil { + sections = append(sections, "budget") + } + if len(resp.DiagnosticEvents) > 0 { + sections = append(sections, "events") + } + if len(resp.Logs) > 0 { + sections = append(sections, "logs") + } + if len(findings) > 0 { + sections = append(sections, "security") + } + if hasTokenFlows { + sections = append(sections, "tokenflow") + } + return sections +} + +func applySimulationFeeMocks(req *simulator.SimulationRequest) { + if req == nil { + return + } + + if mockBaseFeeFlag > 0 { + baseFee := mockBaseFeeFlag + req.MockBaseFee = &baseFee + } + if mockGasPriceFlag > 0 { + gasPrice := mockGasPriceFlag + req.MockGasPrice = &gasPrice + } +} + +var deprecatedSorobanHostFunctions = []string{ + "bytes_copy_from_linear_memory", + "bytes_copy_to_linear_memory", + "bytes_new_from_linear_memory", + "map_new_from_linear_memory", + "map_unpack_to_linear_memory", + "symbol_new_from_linear_memory", + "string_new_from_linear_memory", + "vec_new_from_linear_memory", + "vec_unpack_to_linear_memory", +} + +func deprecatedHostFunctionInDiagnosticEvent(event simulator.DiagnosticEvent) (string, bool) { + if name, ok := findDeprecatedHostFunction(strings.Join(event.Topics, " ")); ok { + return name, true + } + return findDeprecatedHostFunction(event.Data) +} + +func findDeprecatedHostFunction(input string) (string, bool) { + lower := strings.ToLower(input) + for _, fn := range deprecatedSorobanHostFunctions { + if strings.Contains(lower, strings.ToLower(fn)) { + return fn, true + } + } + return "", false +} + +// checkLTOWarning searches the directory tree around a WASM file for +// Cargo.toml files with LTO settings and prints a warning if found. +// It searches the WASM file's parent directory and up to two levels up +// to find the project root. +func checkLTOWarning(wasmFilePath string) { + dir := filepath.Dir(wasmFilePath) + + // Walk up to 3 levels to find Cargo.toml files + for i := 0; i < 3; i++ { + results, err := lto.CheckProjectDir(dir) + if err != nil { + logger.Logger.Debug("LTO check failed", "dir", dir, "error", err) + break + } + if lto.HasLTO(results) { + fmt.Fprintf(os.Stderr, "\n%s\n", lto.FormatWarnings(results)) + return + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } +} +func displaySourceLocation(loc *simulator.SourceLocation) { + fmt.Printf("%s Location: %s:%d:%d\n", visualizer.Symbol("location"), loc.File, loc.Line, loc.Column) + + // Try to find the file + content, err := os.ReadFile(loc.File) + if err != nil { + // Try to find in current directory or src + if c, err := os.ReadFile(filepath.Join("src", loc.File)); err == nil { + content = c + } else { + return + } + } + + lines := strings.Split(string(content), "\n") + if int(loc.Line) > len(lines) { + return + } + + // Show context + start := int(loc.Line) - 3 + if start < 0 { + start = 0 + } + end := int(loc.Line) + 2 + if end > len(lines) { + end = len(lines) + } + + fmt.Println() + for i := start; i < end; i++ { + lineNum := i + 1 + prefix := " " + if lineNum == int(loc.Line) { + prefix = "> " + } + + fmt.Printf("%s %4d | %s\n", prefix, lineNum, lines[i]) + + // Highlight the token if this is the failing line + if lineNum == int(loc.Line) { + // Calculate exact indentation to line up with the printed line + // prefix (2) + lineNum (4) + pipe (3) = 9 spaces + markerIndent := strings.Repeat(" ", 9) + offset := int(loc.Column) - 1 + if offset < 0 { + offset = 0 + } + + highlightLen := 1 + if loc.ColumnEnd != nil && *loc.ColumnEnd > loc.Column { + highlightLen = int(*loc.ColumnEnd - loc.Column) + } + + // Don't exceed line length + if offset < len(lines[i]) { + if offset+highlightLen > len(lines[i]) { + highlightLen = len(lines[i]) - offset + } + marker := strings.Repeat(" ", offset) + strings.Repeat("^", highlightLen) + fmt.Printf(" | %s%s\n", markerIndent[:2], marker) + } + } + } + fmt.Println() +} \ No newline at end of file diff --git a/internal/cmd/debug_test.go b/internal/cmd/debug_test.go index 96e1a1e0..12dcf1f3 100644 --- a/internal/cmd/debug_test.go +++ b/internal/cmd/debug_test.go @@ -175,7 +175,8 @@ func (m *MockRunner) Close() error { } func TestDebugCommand_Setup(t *testing.T) { - // Test that the debugCmd is properly initialized + // Test that the debugCmd is properly initialized via factory + debugCmd := NewDebugCmd() assert.NotNil(t, debugCmd) assert.Equal(t, "debug", debugCmd.Use[:5]) diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 4e08e42e..79f1feda 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -28,11 +28,12 @@ type DependencyStatus struct { FixHint string } -var doctorCmd = &cobra.Command{ - Use: "doctor", - GroupID: "development", - Short: "Diagnose development environment setup", - Long: `Check the status of required dependencies and development tools. +func NewDoctorCmd() *cobra.Command { + doctorCmd := &cobra.Command{ + Use: "doctor", + GroupID: "development", + Short: "Diagnose development environment setup", + Long: `Check the status of required dependencies and development tools. This command verifies: - Go installation and version (matches go.mod) @@ -43,13 +44,16 @@ This command verifies: - Deep link registration (erst:// URL scheme) Use this to troubleshoot installation issues or verify your setup.`, - Example: ` # Check environment status + Example: ` # Check environment status erst doctor # View detailed diagnostics erst doctor --verbose`, - Args: cobra.NoArgs, - RunE: runDoctor, + Args: cobra.NoArgs, + RunE: runDoctor, + } + doctorCmd.Flags().BoolP("verbose", "v", false, "Show detailed diagnostic information") + return doctorCmd } func runDoctor(cmd *cobra.Command, args []string) error { @@ -382,8 +386,3 @@ func buildDeepLinkFixHint(steps []string) string { } return steps[0] } - -func init() { - rootCmd.AddCommand(doctorCmd) - doctorCmd.Flags().BoolP("verbose", "v", false, "Show detailed diagnostic information") -} diff --git a/internal/cmd/doctor_test.go b/internal/cmd/doctor_test.go index cb95d83e..8d9d5a76 100644 --- a/internal/cmd/doctor_test.go +++ b/internal/cmd/doctor_test.go @@ -174,7 +174,8 @@ func TestCheckRPC(t *testing.T) { } func TestDoctorCommand(t *testing.T) { - // Test that the command is registered + // Test that the command is properly initialized via factory + doctorCmd := NewDoctorCmd() if doctorCmd == nil { t.Fatal("doctorCmd should not be nil") } diff --git a/internal/cmd/dry_run.go b/internal/cmd/dry_run.go index 9f07c135..9eff4f14 100644 --- a/internal/cmd/dry_run.go +++ b/internal/cmd/dry_run.go @@ -27,11 +27,12 @@ var ( // NOTE: High-precision fee estimation ultimately depends on network fee configuration. This command // provides a deterministic estimate based on the simulator's reported resource usage, intended as a // safe lower bound / guidance for setting fee/budget. -var dryRunCmd = &cobra.Command{ - Use: "dry-run ", - GroupID: "testing", - Short: "Pre-submission dry run to estimate Soroban transaction cost", - Long: `Replay a local transaction envelope (not yet on chain) against current network state. +func NewDryRunCmd() *cobra.Command { + dryRunCmd := &cobra.Command{ + Use: "dry-run ", + GroupID: "testing", + Short: "Pre-submission dry run to estimate Soroban transaction cost", + Long: `Replay a local transaction envelope (not yet on chain) against current network state. This command: 1) Loads a base64-encoded TransactionEnvelope XDR from a local file @@ -41,25 +42,21 @@ This command: Example: erst dry-run ./tx.xdr --network testnet`, - Args: cobra.ExactArgs(1), - PreRunE: func(cmd *cobra.Command, args []string) error { - // Validate network flag - switch rpc.Network(dryRunNetworkFlag) { - default: - return errors.WrapInvalidNetwork(dryRunNetworkFlag) - } - }, - RunE: runDryRun, -} - -func init() { + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + // Validate network flag + switch rpc.Network(dryRunNetworkFlag) { + default: + return errors.WrapInvalidNetwork(dryRunNetworkFlag) + } + }, + RunE: runDryRun, + } dryRunCmd.Flags().StringVarP(&dryRunNetworkFlag, "network", "n", string(rpc.Mainnet), "Stellar network to use (testnet, mainnet, futurenet)") dryRunCmd.Flags().StringVar(&dryRunRPCURLFlag, "rpc-url", "", "Custom Horizon RPC URL to use") dryRunCmd.Flags().StringVar(&dryRunRPCTokenFlag, "rpc-token", "", "RPC authentication token (can also use ERST_RPC_TOKEN env var)") - _ = dryRunCmd.RegisterFlagCompletionFunc("network", completeNetworkFlag) - - rootCmd.AddCommand(dryRunCmd) + return dryRunCmd } func runDryRun(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/explain.go b/internal/cmd/explain.go index fa26e911..bfffd8e6 100644 --- a/internal/cmd/explain.go +++ b/internal/cmd/explain.go @@ -21,11 +21,12 @@ var ( explainRPCToken string ) -var explainCmd = &cobra.Command{ - Use: "explain [transaction-hash]", - GroupID: "core", - Short: "Summarize why a transaction failed in plain English", - Long: `Apply heuristic analysis to a transaction and output a single-paragraph +func NewExplainCmd() *cobra.Command { + explainCmd := &cobra.Command{ + Use: "explain [transaction-hash]", + GroupID: "core", + Short: "Summarize why a transaction failed in plain English", + Long: `Apply heuristic analysis to a transaction and output a single-paragraph explanation of the root cause of the failure. If a transaction hash is provided the command fetches and simulates it. @@ -35,27 +36,33 @@ Examples: erst explain 5c0a1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab erst explain --network testnet erst debug && erst explain`, - Args: cobra.MaximumNArgs(1), - PreRunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { + Args: cobra.MaximumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + if err := rpc.ValidateTransactionHash(args[0]); err != nil { + return fmt.Errorf("invalid transaction hash: %w", err) + } + switch rpc.Network(explainNetworkFlag) { + case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: + default: + return fmt.Errorf("invalid network: %s; must be testnet, mainnet, or futurenet", explainNetworkFlag) + } return nil - } - if err := rpc.ValidateTransactionHash(args[0]); err != nil { - return fmt.Errorf("invalid transaction hash: %w", err) - } - switch rpc.Network(explainNetworkFlag) { - case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: - default: - return fmt.Errorf("invalid network: %s; must be testnet, mainnet, or futurenet", explainNetworkFlag) - } - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return explainFromSession() - } - return explainFromNetwork(cmd, args[0]) - }, + }, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return explainFromSession() + } + return explainFromNetwork(cmd, args[0]) + }, + } + explainCmd.Flags().StringVarP(&explainNetworkFlag, "network", "n", "mainnet", "Stellar network (testnet, mainnet, futurenet)") + explainCmd.Flags().StringVar(&explainRPCURLFlag, "rpc-url", "", "Custom RPC URL") + explainCmd.Flags().StringVar(&explainRPCToken, "rpc-token", "", "RPC authentication token (can also use ERST_RPC_TOKEN env var)") + _ = explainCmd.RegisterFlagCompletionFunc("network", completeNetworkFlag) + return explainCmd } func explainFromSession() error { @@ -154,13 +161,3 @@ func explainFromNetwork(cmd *cobra.Command, txHash string) error { fmt.Println(heuristic.Summarize(in)) return nil } - -func init() { - explainCmd.Flags().StringVarP(&explainNetworkFlag, "network", "n", "mainnet", "Stellar network (testnet, mainnet, futurenet)") - explainCmd.Flags().StringVar(&explainRPCURLFlag, "rpc-url", "", "Custom RPC URL") - explainCmd.Flags().StringVar(&explainRPCToken, "rpc-token", "", "RPC authentication token (can also use ERST_RPC_TOKEN env var)") - - _ = explainCmd.RegisterFlagCompletionFunc("network", completeNetworkFlag) - - rootCmd.AddCommand(explainCmd) -} diff --git a/internal/cmd/export.go b/internal/cmd/export.go index c041110d..5ee3668b 100644 --- a/internal/cmd/export.go +++ b/internal/cmd/export.go @@ -21,62 +21,71 @@ var decodeSnapshotFlag string var decodeOffsetFlag int var decodeLengthFlag int -var exportCmd = &cobra.Command{ - Use: "export", - GroupID: "utility", - Short: "Export data from the current session", - Long: `Export debugging data, such as state snapshots, from the currently active session.`, - RunE: func(cmd *cobra.Command, args []string) error { - if exportSnapshotFlag == "" { - return errors.WrapCliArgumentRequired("snapshot") - } - - // Get current session - data := GetCurrentSession() - if data == nil { - return errors.WrapSimulationLogicError("no active session. Run 'erst debug ' first") - } +func NewExportCmd() *cobra.Command { + exportCmd := &cobra.Command{ + Use: "export", + GroupID: "utility", + Short: "Export data from the current session", + Long: `Export debugging data, such as state snapshots, from the currently active session.`, + RunE: func(cmd *cobra.Command, args []string) error { + if exportSnapshotFlag == "" { + return errors.WrapCliArgumentRequired("snapshot") + } - // Unwrap simulation request to get ledger entries - var simReq simulator.SimulationRequest - if err := json.Unmarshal([]byte(data.SimRequestJSON), &simReq); err != nil { - return errors.WrapUnmarshalFailed(err, "session data") - } + // Get current session + data := GetCurrentSession() + if data == nil { + return errors.WrapSimulationLogicError("no active session. Run 'erst debug ' first") + } - if len(simReq.LedgerEntries) == 0 { - fmt.Println("Warning: No ledger entries found in the current session.") - } + // Unwrap simulation request to get ledger entries + var simReq simulator.SimulationRequest + if err := json.Unmarshal([]byte(data.SimRequestJSON), &simReq); err != nil { + return errors.WrapUnmarshalFailed(err, "session data") + } - var memoryDump []byte - if exportIncludeMemoryFlag { - var simResp simulator.SimulationResponse - if err := json.Unmarshal([]byte(data.SimResponseJSON), &simResp); err != nil { - return errors.WrapUnmarshalFailed(err, "simulation response") + if len(simReq.LedgerEntries) == 0 { + fmt.Println("Warning: No ledger entries found in the current session.") } - if simResp.LinearMemoryDump == "" { - fmt.Println("Warning: Simulator response does not include a linear memory dump.") - } else { - decoded, err := base64.StdEncoding.DecodeString(simResp.LinearMemoryDump) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to decode simulator linear memory dump: %v", err)) + + var memoryDump []byte + if exportIncludeMemoryFlag { + var simResp simulator.SimulationResponse + if err := json.Unmarshal([]byte(data.SimResponseJSON), &simResp); err != nil { + return errors.WrapUnmarshalFailed(err, "simulation response") + } + if simResp.LinearMemoryDump == "" { + fmt.Println("Warning: Simulator response does not include a linear memory dump.") + } else { + decoded, err := base64.StdEncoding.DecodeString(simResp.LinearMemoryDump) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to decode simulator linear memory dump: %v", err)) + } + memoryDump = decoded } - memoryDump = decoded } - } - snap := snapshot.FromMapWithOptions(simReq.LedgerEntries, snapshot.BuildOptions{LinearMemory: memoryDump}) + snap := snapshot.FromMapWithOptions(simReq.LedgerEntries, snapshot.BuildOptions{LinearMemory: memoryDump}) - // Save - if err := snapshot.Save(exportSnapshotFlag, snap); err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to save snapshot: %v", err)) - } + // Save + if err := snapshot.Save(exportSnapshotFlag, snap); err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to save snapshot: %v", err)) + } - fmt.Printf("Snapshot exported to %s (%d entries)\n", exportSnapshotFlag, len(snap.LedgerEntries)) - if snap.LinearMemory != "" { - fmt.Printf("Included linear memory dump: %d bytes (base64)\n", len(memoryDump)) - } - return nil - }, + fmt.Printf("Snapshot exported to %s (%d entries)\n", exportSnapshotFlag, len(snap.LedgerEntries)) + if snap.LinearMemory != "" { + fmt.Printf("Included linear memory dump: %d bytes (base64)\n", len(memoryDump)) + } + return nil + }, + } + exportCmd.Flags().StringVar(&exportSnapshotFlag, "snapshot", "", "Output file for JSON snapshot") + exportCmd.Flags().BoolVar(&exportIncludeMemoryFlag, "include-memory", false, "Include Wasm linear memory dump from simulation response when available") + exportDecodeMemoryCmd.Flags().StringVar(&decodeSnapshotFlag, "snapshot", "", "Snapshot file that contains linear memory") + exportDecodeMemoryCmd.Flags().IntVar(&decodeOffsetFlag, "offset", 0, "Start offset in bytes") + exportDecodeMemoryCmd.Flags().IntVar(&decodeLengthFlag, "length", 256, "Number of bytes to print") + exportCmd.AddCommand(exportDecodeMemoryCmd) + return exportCmd } var exportDecodeMemoryCmd = &cobra.Command{ @@ -146,18 +155,6 @@ var exportDecodeMemoryCmd = &cobra.Command{ }, } -func init() { - exportCmd.Flags().StringVar(&exportSnapshotFlag, "snapshot", "", "Output file for JSON snapshot") - exportCmd.Flags().BoolVar(&exportIncludeMemoryFlag, "include-memory", false, "Include Wasm linear memory dump from simulation response when available") - - exportDecodeMemoryCmd.Flags().StringVar(&decodeSnapshotFlag, "snapshot", "", "Snapshot file that contains linear memory") - exportDecodeMemoryCmd.Flags().IntVar(&decodeOffsetFlag, "offset", 0, "Start offset in bytes") - exportDecodeMemoryCmd.Flags().IntVar(&decodeLengthFlag, "length", 256, "Number of bytes to print") - - exportCmd.AddCommand(exportDecodeMemoryCmd) - rootCmd.AddCommand(exportCmd) -} - func extractLinearMemoryBase64(simResponseJSON string) (string, error) { if simResponseJSON == "" { return "", nil diff --git a/internal/cmd/fuzz.go b/internal/cmd/fuzz.go index fe8061aa..77391592 100644 --- a/internal/cmd/fuzz.go +++ b/internal/cmd/fuzz.go @@ -20,11 +20,12 @@ var ( fuzzTargetContract string ) -var fuzzCmd = &cobra.Command{ - Use: "fuzz", - GroupID: "testing", - Short: "Fuzz test XDR inputs against Soroban contracts", - Long: `Perform fuzzing of XDR inputs to discover edge cases and potential crashes +func NewFuzzCmd() *cobra.Command { + fuzzCmd := &cobra.Command{ + Use: "fuzz", + GroupID: "testing", + Short: "Fuzz test XDR inputs against Soroban contracts", + Long: `Perform fuzzing of XDR inputs to discover edge cases and potential crashes in Soroban contract execution. This command uses coverage-guided fuzzing to generate random inputs that are @@ -38,7 +39,45 @@ Examples: erst fuzz --iterations 10000 erst fuzz --iterations 50000 --workers 8 erst fuzz --xdr --iterations 5000`, - RunE: runFuzz, + RunE: runFuzz, + } + fuzzCmd.Flags().Uint64Var( + &fuzzIterations, + "iterations", + 0, + "Number of fuzzing iterations (required)", + ) + fuzzCmd.Flags().Uint64Var( + &fuzzTimeout, + "timeout", + 5000, + "Timeout per fuzz iteration in milliseconds", + ) + fuzzCmd.Flags().IntVar( + &fuzzMaxSize, + "max-size", + 262144, + "Maximum input size in bytes (default 256KB)", + ) + fuzzCmd.Flags().StringVar( + &fuzzInputXDR, + "xdr", + "", + "Optional base XDR input to fuzz (hex-encoded)", + ) + fuzzCmd.Flags().BoolVar( + &fuzzEnableCov, + "coverage", + false, + "Enable code coverage tracking (requires instrumented binary)", + ) + fuzzCmd.Flags().StringVar( + &fuzzTargetContract, + "target", + "", + "Optional target contract ID to focus fuzzing on", + ) + return fuzzCmd } func runFuzz(cmd *cobra.Command, args []string) error { @@ -166,49 +205,3 @@ func min(a, b int) int { } return b } - -func init() { - fuzzCmd.Flags().Uint64Var( - &fuzzIterations, - "iterations", - 0, - "Number of fuzzing iterations (required)", - ) - - fuzzCmd.Flags().Uint64Var( - &fuzzTimeout, - "timeout", - 5000, - "Timeout per fuzz iteration in milliseconds", - ) - - fuzzCmd.Flags().IntVar( - &fuzzMaxSize, - "max-size", - 262144, - "Maximum input size in bytes (default 256KB)", - ) - - fuzzCmd.Flags().StringVar( - &fuzzInputXDR, - "xdr", - "", - "Optional base XDR input to fuzz (hex-encoded)", - ) - - fuzzCmd.Flags().BoolVar( - &fuzzEnableCov, - "coverage", - false, - "Enable code coverage tracking (requires instrumented binary)", - ) - - fuzzCmd.Flags().StringVar( - &fuzzTargetContract, - "target", - "", - "Optional target contract ID to focus fuzzing on", - ) - - rootCmd.AddCommand(fuzzCmd) -} diff --git a/internal/cmd/generate_test.go b/internal/cmd/generate_test.go index 2e0dc5e3..254b3d25 100644 --- a/internal/cmd/generate_test.go +++ b/internal/cmd/generate_test.go @@ -17,11 +17,12 @@ var ( genTestName string ) -var generateTestCmd = &cobra.Command{ - Use: "generate-test ", - GroupID: "testing", - Short: "Generate regression tests from a transaction", - Long: `Generate regression tests from a recorded transaction trace. +func NewGenerateTestCmd() *cobra.Command { + generateTestCmd := &cobra.Command{ + Use: "generate-test ", + GroupID: "testing", + Short: "Generate regression tests from a transaction", + Long: `Generate regression tests from a recorded transaction trace. This creates test files that can be used to ensure bugs don't reoccur. The command fetches the transaction data from the network and generates @@ -30,50 +31,47 @@ test files in Go and/or Rust that replay the transaction. Example: erst generate-test 5c0a1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab erst generate-test --lang go --name my_test `, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - txHash := args[0] + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + txHash := args[0] - // Create RPC client - opts := []rpc.ClientOption{ - rpc.WithNetwork(rpc.Network(networkFlag)), - rpc.WithToken(rpcTokenFlag), - } - if rpcURLFlag != "" { - opts = append(opts, rpc.WithHorizonURL(rpcURLFlag)) - } + // Create RPC client + opts := []rpc.ClientOption{ + rpc.WithNetwork(rpc.Network(networkFlag)), + rpc.WithToken(rpcTokenFlag), + } + if rpcURLFlag != "" { + opts = append(opts, rpc.WithHorizonURL(rpcURLFlag)) + } - client, err := rpc.NewClient(opts...) - if err != nil { - return fmt.Errorf("failed to create client: %w", err) - } + client, err := rpc.NewClient(opts...) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } - // Get current working directory as default output - if genTestOutput == "" { - genTestOutput = "." - } + // Get current working directory as default output + if genTestOutput == "" { + genTestOutput = "." + } - // Create test generator - generator := testgen.NewTestGenerator(client, genTestOutput) + // Create test generator + generator := testgen.NewTestGenerator(client, genTestOutput) - // Generate tests - fmt.Printf("Generating %s regression test(s) for transaction: %s\n", genTestLang, txHash) - if err := generator.GenerateTests(cmd.Context(), txHash, genTestLang, genTestName); err != nil { - return fmt.Errorf("failed to generate tests: %w", err) - } + // Generate tests + fmt.Printf("Generating %s regression test(s) for transaction: %s\n", genTestLang, txHash) + if err := generator.GenerateTests(cmd.Context(), txHash, genTestLang, genTestName); err != nil { + return fmt.Errorf("failed to generate tests: %w", err) + } - fmt.Println("[OK] Test generation completed successfully") - return nil - }, -} - -func init() { + fmt.Println("[OK] Test generation completed successfully") + return nil + }, + } generateTestCmd.Flags().StringVarP(&genTestLang, "lang", "l", "both", "Target language (go, rust, or both)") generateTestCmd.Flags().StringVarP(&genTestOutput, "output", "o", "", "Output directory (defaults to current directory)") generateTestCmd.Flags().StringVarP(&genTestName, "name", "", "", "Custom test name (defaults to transaction hash)") generateTestCmd.Flags().StringVarP(&networkFlag, "network", "n", string(rpc.Mainnet), "Stellar network to use (testnet, mainnet, futurenet)") generateTestCmd.Flags().StringVar(&rpcURLFlag, "rpc-url", "", "Custom Horizon RPC URL to use") generateTestCmd.Flags().StringVar(&rpcTokenFlag, "rpc-token", "", "RPC authentication token (can also use ERST_RPC_TOKEN env var)") - - rootCmd.AddCommand(generateTestCmd) + return generateTestCmd } diff --git a/internal/cmd/init.go b/internal/cmd/init.go index 07e084e2..17dcb59f 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -25,11 +25,12 @@ var ( initInteractiveFlag bool ) -var initCmd = &cobra.Command{ - Use: "init [directory]", - GroupID: "development", - Short: "Scaffold a local Erst debugging workspace", - Long: `Create project-local scaffolding for Erst debugging workflows. +func NewInitCmd() *cobra.Command { + initCmd := &cobra.Command{ + Use: "init [directory]", + GroupID: "development", + Short: "Scaffold a local Erst debugging workspace", + Long: `Create project-local scaffolding for Erst debugging workflows. This command generates: - erst.toml @@ -38,37 +39,45 @@ This command generates: When run in an interactive terminal, it launches a setup wizard to configure the preferred RPC URL and network passphrase.`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - targetDir := "." - if len(args) == 1 { - targetDir = args[0] - } + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + targetDir := "." + if len(args) == 1 { + targetDir = args[0] + } - opts := initScaffoldOptions{ - Force: initForceFlag, - Network: initNetworkName, - RPCURL: initRPCURLFlag, - NetworkPassphrase: initNetworkPassphraseFlag, - } + opts := initScaffoldOptions{ + Force: initForceFlag, + Network: initNetworkName, + RPCURL: initRPCURLFlag, + NetworkPassphrase: initNetworkPassphraseFlag, + } - if !isValidInitNetwork(opts.Network) { - return fmt.Errorf("invalid network %q (valid: public, testnet, futurenet, standalone)", opts.Network) - } + if !isValidInitNetwork(opts.Network) { + return fmt.Errorf("invalid network %q (valid: public, testnet, futurenet, standalone)", opts.Network) + } - if shouldRunInitWizard(cmd, initInteractiveFlag) { - if err := runInitWizard(cmd, &opts); err != nil { - return err + if shouldRunInitWizard(cmd, initInteractiveFlag) { + if err := runInitWizard(cmd, &opts); err != nil { + return err + } } - } - if err := scaffoldErstProject(targetDir, opts); err != nil { - return err - } + if err := scaffoldErstProject(targetDir, opts); err != nil { + return err + } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Initialized Erst project scaffold in %s\n", targetDir) //nolint:errcheck - return nil - }, + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Initialized Erst project scaffold in %s\n", targetDir) //nolint:errcheck + return nil + }, + } + initCmd.Flags().BoolVar(&initForceFlag, "force", false, "Overwrite generated files when they already exist") + initCmd.Flags().BoolVar(&initInteractiveFlag, "interactive", true, "Run an interactive setup wizard for RPC URL and network passphrase") + initCmd.Flags().StringVar(&initNetworkName, "network", "testnet", "Default network to write into erst.toml (public, testnet, futurenet, standalone)") + initCmd.Flags().StringVar(&initRPCURLFlag, "rpc-url", "", "RPC URL to write into erst.toml (skips wizard default for this value)") + initCmd.Flags().StringVar(&initNetworkPassphraseFlag, "network-passphrase", "", "Network passphrase to write into erst.toml (skips wizard default for this value)") + _ = initCmd.RegisterFlagCompletionFunc("network", completeInitNetworkFlag) + return initCmd } type initScaffoldOptions struct { @@ -298,15 +307,3 @@ func isValidInitNetwork(network string) bool { } return false } - -func init() { - initCmd.Flags().BoolVar(&initForceFlag, "force", false, "Overwrite generated files when they already exist") - initCmd.Flags().BoolVar(&initInteractiveFlag, "interactive", true, "Run an interactive setup wizard for RPC URL and network passphrase") - initCmd.Flags().StringVar(&initNetworkName, "network", "testnet", "Default network to write into erst.toml (public, testnet, futurenet, standalone)") - initCmd.Flags().StringVar(&initRPCURLFlag, "rpc-url", "", "RPC URL to write into erst.toml (skips wizard default for this value)") - initCmd.Flags().StringVar(&initNetworkPassphraseFlag, "network-passphrase", "", "Network passphrase to write into erst.toml (skips wizard default for this value)") - - _ = initCmd.RegisterFlagCompletionFunc("network", completeInitNetworkFlag) - - rootCmd.AddCommand(initCmd) -} diff --git a/internal/cmd/mem_bench_test.go b/internal/cmd/mem_bench_test.go new file mode 100644 index 00000000..83426d9b --- /dev/null +++ b/internal/cmd/mem_bench_test.go @@ -0,0 +1,12 @@ +package cmd + +import ( + "testing" +) + +func BenchmarkCLIInit(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = NewRootCmd() + } +} diff --git a/internal/cmd/offline.go b/internal/cmd/offline.go index e2bc8414..634a703c 100644 --- a/internal/cmd/offline.go +++ b/internal/cmd/offline.go @@ -25,10 +25,11 @@ var ( ) // offlineCmd is the parent command for the air-gapped signing workflow. -var offlineCmd = &cobra.Command{ - Use: "offline", - Short: "Air-gapped transaction signing pipeline", - Long: `Manage the offline (air-gapped) transaction signing workflow. +func NewOfflineCmd() *cobra.Command { + offlineCmd := &cobra.Command{ + Use: "offline", + Short: "Air-gapped transaction signing pipeline", + Long: `Manage the offline (air-gapped) transaction signing workflow. The pipeline has four stages: 1. generate – Build an unsigned envelope and save it to a portable JSON file. @@ -46,6 +47,22 @@ Example workflow: # Copy signed tx.erst.json back via USB erst offline verify tx.erst.json erst offline submit tx.erst.json`, + } + // generate flags + offlineGenerateCmd.Flags().StringVarP(&offlineNetworkFlag, "network", "n", string(rpc.Mainnet), "Target Stellar network (testnet, mainnet, futurenet)") + offlineGenerateCmd.Flags().StringVarP(&offlineOutputFlag, "output", "o", "", "Output file path (default: unsigned.erst.json)") + offlineGenerateCmd.Flags().StringVar(&offlineDescFlag, "desc", "", "Human-readable description to embed in the file") + offlineGenerateCmd.Flags().StringVar(&offlineSourceFlag, "source", "", "Source account address to embed in metadata") + // sign flags + offlineSignCmd.Flags().StringVar(&offlineKeyFlag, "key", "", "Hex-encoded ed25519 private key (32-byte seed or 64-byte full key)") + // submit flags + offlineSubmitCmd.Flags().StringVar(&offlineRPCURLFlag, "rpc-url", "", "Custom Soroban RPC URL (overrides network default)") + // wire tree + offlineCmd.AddCommand(offlineGenerateCmd) + offlineCmd.AddCommand(offlineSignCmd) + offlineCmd.AddCommand(offlineVerifyCmd) + offlineCmd.AddCommand(offlineSubmitCmd) + return offlineCmd } // ── generate ──────────────────────────────────────────────────────────────── @@ -276,25 +293,3 @@ func isSpace(c byte) bool { } // ── init ──────────────────────────────────────────────────────────────────── - -func init() { - // generate flags - offlineGenerateCmd.Flags().StringVarP(&offlineNetworkFlag, "network", "n", string(rpc.Mainnet), "Target Stellar network (testnet, mainnet, futurenet)") - offlineGenerateCmd.Flags().StringVarP(&offlineOutputFlag, "output", "o", "", "Output file path (default: unsigned.erst.json)") - offlineGenerateCmd.Flags().StringVar(&offlineDescFlag, "desc", "", "Human-readable description to embed in the file") - offlineGenerateCmd.Flags().StringVar(&offlineSourceFlag, "source", "", "Source account address to embed in metadata") - - // sign flags - offlineSignCmd.Flags().StringVar(&offlineKeyFlag, "key", "", "Hex-encoded ed25519 private key (32-byte seed or 64-byte full key)") - - // submit flags - offlineSubmitCmd.Flags().StringVar(&offlineRPCURLFlag, "rpc-url", "", "Custom Soroban RPC URL (overrides network default)") - - // wire tree - offlineCmd.AddCommand(offlineGenerateCmd) - offlineCmd.AddCommand(offlineSignCmd) - offlineCmd.AddCommand(offlineVerifyCmd) - offlineCmd.AddCommand(offlineSubmitCmd) - - rootCmd.AddCommand(offlineCmd) -} diff --git a/internal/cmd/profile.go b/internal/cmd/profile.go index 795dc710..c56a534b 100644 --- a/internal/cmd/profile.go +++ b/internal/cmd/profile.go @@ -17,65 +17,64 @@ var ( profileOutput string ) -var profileCmd = &cobra.Command{ - Use: "profile [trace-file]", - GroupID: "utility", - Short: "Export trace as pprof profile for gas-to-function mapping", - Long: `Synthesize trace events into a pprof-compliant profile that maps gas +func NewProfileCmd() *cobra.Command { + profileCmd := &cobra.Command{ + Use: "profile [trace-file]", + GroupID: "utility", + Short: "Export trace as pprof profile for gas-to-function mapping", + Long: `Synthesize trace events into a pprof-compliant profile that maps gas consumption to functions. The output can be viewed with go tool pprof. Example: erst profile execution.json -o gas.pb.gz erst profile --file debug_trace.json -o gas.pb.gz go tool pprof gas.pb.gz`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - var filename string - if len(args) > 0 { - filename = args[0] - } else if profileTraceFile != "" { - filename = profileTraceFile - } else { - return fmt.Errorf("trace file required. Use: erst profile or --file ") - } + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var filename string + if len(args) > 0 { + filename = args[0] + } else if profileTraceFile != "" { + filename = profileTraceFile + } else { + return fmt.Errorf("trace file required. Use: erst profile or --file ") + } - if _, err := os.Stat(filename); os.IsNotExist(err) { - return fmt.Errorf("trace file not found: %s", filename) - } + if _, err := os.Stat(filename); os.IsNotExist(err) { + return fmt.Errorf("trace file not found: %s", filename) + } - data, err := os.ReadFile(filename) - if err != nil { - return fmt.Errorf("failed to read trace file: %w", err) - } + data, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("failed to read trace file: %w", err) + } - execTrace, err := trace.FromJSON(data) - if err != nil { - return fmt.Errorf("failed to parse trace file: %w", err) - } + execTrace, err := trace.FromJSON(data) + if err != nil { + return fmt.Errorf("failed to parse trace file: %w", err) + } - outPath := profileOutput - if outPath == "" { - outPath = "profile.pb.gz" - } + outPath := profileOutput + if outPath == "" { + outPath = "profile.pb.gz" + } - out, err := os.Create(outPath) - if err != nil { - return fmt.Errorf("failed to create output file: %w", err) - } - defer out.Close() + out, err := os.Create(outPath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer out.Close() - if err := profile.WritePprof(execTrace, out); err != nil { - return fmt.Errorf("failed to write pprof profile: %w", err) - } + if err := profile.WritePprof(execTrace, out); err != nil { + return fmt.Errorf("failed to write pprof profile: %w", err) + } - fmt.Printf("Profile written to %s\n", outPath) - fmt.Printf("View with: go tool pprof %s\n", outPath) - return nil - }, -} - -func init() { + fmt.Printf("Profile written to %s\n", outPath) + fmt.Printf("View with: go tool pprof %s\n", outPath) + return nil + }, + } profileCmd.Flags().StringVarP(&profileTraceFile, "file", "f", "", "Trace file to load") profileCmd.Flags().StringVarP(&profileOutput, "output", "o", "profile.pb.gz", "Output pprof file path") - rootCmd.AddCommand(profileCmd) + return profileCmd } diff --git a/internal/cmd/regression_test.go b/internal/cmd/regression_test.go index a71a5fb4..f58d7230 100644 --- a/internal/cmd/regression_test.go +++ b/internal/cmd/regression_test.go @@ -18,26 +18,6 @@ var ( regressionMaxWorkers int ) -var regressionTestCmd = &cobra.Command{ - Use: "regression-test", - GroupID: "testing", - Short: "Run protocol regression tests against historic transactions", - Long: `Execute a comprehensive regression test suite by downloading historic failed -transactions from Mainnet and ensuring erst-sim yields identical results. - -This command fetches up to the specified number of failed transactions and -simulates them in parallel, verifying that the simulator produces the same -traps and events as the original network execution. - -The tests help ensure that protocol changes don't introduce regressions. - -Example: - erst regression-test --count 100 - erst regression-test --count 1000 --workers 8 - erst regression-test --count 500 --network mainnet --protocol-version 22`, - RunE: runRegressionTest, -} - func runRegressionTest(cmd *cobra.Command, args []string) error { if regressionTestCount <= 0 { return fmt.Errorf("--count must be greater than 0") @@ -119,7 +99,27 @@ func runRegressionTest(cmd *cobra.Command, args []string) error { return nil } -func init() { +func NewRegressionTestCmd() *cobra.Command { + regressionTestCmd := &cobra.Command{ + Use: "regression-test", + GroupID: "testing", + Short: "Run protocol regression tests against historic transactions", + Long: `Execute a comprehensive regression test suite by downloading historic failed +transactions from Mainnet and ensuring erst-sim yields identical results. + +This command fetches up to the specified number of failed transactions and +simulates them in parallel, verifying that the simulator produces the same +traps and events as the original network execution. + +The tests help ensure that protocol changes don't introduce regressions. + +Example: + erst regression-test --count 100 + erst regression-test --count 1000 --workers 8 + erst regression-test --count 500 --network mainnet --protocol-version 22`, + RunE: runRegressionTest, + } + regressionTestCmd.Flags().IntVar( ®ressionTestCount, "count", @@ -178,5 +178,5 @@ func init() { "Enable verbose output", ) - rootCmd.AddCommand(regressionTestCmd) + return regressionTestCmd } diff --git a/internal/cmd/report.go b/internal/cmd/report.go index cbd4e0f5..c764b903 100644 --- a/internal/cmd/report.go +++ b/internal/cmd/report.go @@ -21,11 +21,12 @@ var ( reportFile string ) -var reportCmd = &cobra.Command{ - Use: "report", - GroupID: "utility", - Short: "Generate debugging reports from traces", - Long: `Generate professional PDF or HTML reports from execution traces. +func NewReportCmd() *cobra.Command { + reportCmd := &cobra.Command{ + Use: "report", + GroupID: "utility", + Short: "Generate debugging reports from traces", + Long: `Generate professional PDF or HTML reports from execution traces. Reports include: - Executive summary with key findings @@ -38,7 +39,13 @@ Examples: erst report --file trace.json --format html --output reports/ erst report --file trace.json --format pdf --output reports/ erst report --file trace.json --format html,pdf --output reports/`, - RunE: reportExec, + RunE: reportExec, + } + reportCmd.Flags().StringVar(&reportFormat, "format", "html", "Output format: html, pdf, json, or html,pdf") + reportCmd.Flags().StringVar(&reportOutput, "output", ".", "Output directory for reports") + reportCmd.Flags().StringVar(&reportFile, "file", "", "Trace file to analyze") + _ = reportCmd.RegisterFlagCompletionFunc("format", completeReportFormatFlag) + return reportCmd } func reportExec(cmd *cobra.Command, args []string) error { @@ -214,13 +221,3 @@ func calculateRiskScore(states []trace.ExecutionState) float64 { errorCount := countErrors(states) return (float64(errorCount) / float64(len(states))) * 100 } - -func init() { - reportCmd.Flags().StringVar(&reportFormat, "format", "html", "Output format: html, pdf, json, or html,pdf") - reportCmd.Flags().StringVar(&reportOutput, "output", ".", "Output directory for reports") - reportCmd.Flags().StringVar(&reportFile, "file", "", "Trace file to analyze") - - _ = reportCmd.RegisterFlagCompletionFunc("format", completeReportFormatFlag) - - rootCmd.AddCommand(reportCmd) -} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 99e4072b..84eb6a7a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -29,7 +29,8 @@ var ( ) // rootCmd represents the base command when called without any subcommands -var rootCmd = &cobra.Command{ +func NewRootCmd() *cobra.Command { + rootCmd := &cobra.Command{ Use: "erst", Short: "Soroban smart contract debugger and transaction analyzer", Long: `Erst is a specialized developer tool for the Stellar network that helps you @@ -73,6 +74,100 @@ Get started with 'erst debug --help' or visit the documentation.`, SilenceUsage: true, SilenceErrors: true, Version: Version, + } + + // Root command initialization + rootCmd.PersistentFlags().Int64Var( + &TimestampFlag, + "timestamp", + 0, + "Override the ledger header timestamp (Unix epoch)", + ) + + rootCmd.PersistentFlags().Int64Var( + &WindowFlag, + "window", + 0, + "Run range simulation across a time window (seconds)", + ) + + rootCmd.PersistentFlags().BoolVar( + &ProfileFlag, + "profile", + false, + "Enable CPU/Memory profiling and generate a flamegraph", + ) + + rootCmd.PersistentFlags().StringVar( + &ProfileFormatFlag, + "profile-format", + "html", + "Flamegraph export format: 'html' (interactive) or 'svg' (raw)", + ) + + rootCmd.PersistentFlags().StringVar( + &DeepLinkFlag, + "deep-link", + "", + "Handle an erst:// deep link URL (used internally by the doctor probe)", + ) + // Hide from normal help output; it is an internal dispatch mechanism. + _ = rootCmd.PersistentFlags().MarkHidden("deep-link") + + // Define command groups for better organization + rootCmd.AddGroup(&cobra.Group{ + ID: "core", + Title: "Core Debugging Commands:", + }) + rootCmd.AddGroup(&cobra.Group{ + ID: "testing", + Title: "Testing & Validation Commands:", + }) + rootCmd.AddGroup(&cobra.Group{ + ID: "management", + Title: "Session & Cache Management:", + }) + rootCmd.AddGroup(&cobra.Group{ + ID: "development", + Title: "Development Tools:", + }) + rootCmd.AddGroup(&cobra.Group{ + ID: "utility", + Title: "Utility Commands:", + }) + rootCmd.AddCommand(NewAbiCmd()) + rootCmd.AddCommand(NewAuthDebugCmd()) + rootCmd.AddCommand(NewCacheCmd()) + rootCmd.AddCommand(NewCompareCmd()) + rootCmd.AddCommand(NewCompletionCmd()) + rootCmd.AddCommand(NewDaemonCmd()) + rootCmd.AddCommand(NewDceCmd()) + rootCmd.AddCommand(NewDebugCmd()) + rootCmd.AddCommand(NewDoctorCmd()) + rootCmd.AddCommand(NewDryRunCmd()) + rootCmd.AddCommand(NewExplainCmd()) + rootCmd.AddCommand(NewExportCmd()) + rootCmd.AddCommand(NewFuzzCmd()) + rootCmd.AddCommand(NewInitCmd()) + rootCmd.AddCommand(NewOfflineCmd()) + rootCmd.AddCommand(NewProfileCmd()) + rootCmd.AddCommand(NewReportCmd()) + rootCmd.AddCommand(NewRpcCmd()) + rootCmd.AddCommand(NewSandboxCmd()) + rootCmd.AddCommand(NewSearchCmd()) + rootCmd.AddCommand(NewSessionCmd()) + rootCmd.AddCommand(NewShellCmd()) + rootCmd.AddCommand(NewSnapshotDiffCmd()) + rootCmd.AddCommand(NewSnapshotMemoryCmd()) + rootCmd.AddCommand(NewStatsCmd()) + rootCmd.AddCommand(NewStatusCmd()) + rootCmd.AddCommand(NewTraceCmd()) + rootCmd.AddCommand(NewUpgradeCmd()) + rootCmd.AddCommand(NewVersionCmd()) + rootCmd.AddCommand(NewWizardCmd()) + rootCmd.AddCommand(NewXdrCmd()) + + return rootCmd } // Execute adds all child commands to the root command and sets flags appropriately. @@ -90,7 +185,7 @@ func Execute() error { defer clearShutdownCoordinator() return executeWithSignals(ctx, stop, sigCh, coordinator, func(execCtx context.Context) error { - return rootCmd.ExecuteContext(execCtx) + return NewRootCmd().ExecuteContext(execCtx) }) } @@ -175,67 +270,3 @@ func handleDeepLinkProbe(rawURL string) error { return nil } -func init() { - // Root command initialization - rootCmd.PersistentFlags().Int64Var( - &TimestampFlag, - "timestamp", - 0, - "Override the ledger header timestamp (Unix epoch)", - ) - - rootCmd.PersistentFlags().Int64Var( - &WindowFlag, - "window", - 0, - "Run range simulation across a time window (seconds)", - ) - - rootCmd.PersistentFlags().BoolVar( - &ProfileFlag, - "profile", - false, - "Enable CPU/Memory profiling and generate a flamegraph", - ) - - rootCmd.PersistentFlags().StringVar( - &ProfileFormatFlag, - "profile-format", - "html", - "Flamegraph export format: 'html' (interactive) or 'svg' (raw)", - ) - - rootCmd.PersistentFlags().StringVar( - &DeepLinkFlag, - "deep-link", - "", - "Handle an erst:// deep link URL (used internally by the doctor probe)", - ) - // Hide from normal help output; it is an internal dispatch mechanism. - _ = rootCmd.PersistentFlags().MarkHidden("deep-link") - - // Define command groups for better organization - rootCmd.AddGroup(&cobra.Group{ - ID: "core", - Title: "Core Debugging Commands:", - }) - rootCmd.AddGroup(&cobra.Group{ - ID: "testing", - Title: "Testing & Validation Commands:", - }) - rootCmd.AddGroup(&cobra.Group{ - ID: "management", - Title: "Session & Cache Management:", - }) - rootCmd.AddGroup(&cobra.Group{ - ID: "development", - Title: "Development Tools:", - }) - rootCmd.AddGroup(&cobra.Group{ - ID: "utility", - Title: "Utility Commands:", - }) - - // Register commands - rootCmd.AddCommand(statsCmd) -} diff --git a/internal/cmd/rpc.go b/internal/cmd/rpc.go index 9c56725f..579b0c79 100644 --- a/internal/cmd/rpc.go +++ b/internal/cmd/rpc.go @@ -17,10 +17,19 @@ var ( rpcHealthURLFlag string ) -var rpcCmd = &cobra.Command{ - Use: "rpc", - GroupID: "utility", - Short: "Manage and monitor RPC endpoints", +func NewRpcCmd() *cobra.Command { + rpcCmd := &cobra.Command{ + Use: "rpc", + GroupID: "utility", + Short: "Manage and monitor RPC endpoints", + } + rpcHealthCmd.Flags().StringVar(&rpcHealthURLFlag, "rpc", "", "RPC URLs to check (comma-separated)") + rpcCmd.AddCommand(rpcHealthCmd) + // Add the rpc:health as a top-level command for compatibility + rpcHealthAliasCmd := *rpcHealthCmd + rpcHealthAliasCmd.Use = "rpc:health" + rpcHealthAliasCmd.Hidden = true + return rpcCmd } var rpcHealthCmd = &cobra.Command{ @@ -96,16 +105,3 @@ var rpcHealthCmd = &cobra.Command{ return nil }, } - -func init() { - rpcHealthCmd.Flags().StringVar(&rpcHealthURLFlag, "rpc", "", "RPC URLs to check (comma-separated)") - rpcCmd.AddCommand(rpcHealthCmd) - - // Add the rpc:health as a top-level command for compatibility - rpcHealthAliasCmd := *rpcHealthCmd - rpcHealthAliasCmd.Use = "rpc:health" - rpcHealthAliasCmd.Hidden = true - rootCmd.AddCommand(&rpcHealthAliasCmd) - - rootCmd.AddCommand(rpcCmd) -} diff --git a/internal/cmd/sandbox.go b/internal/cmd/sandbox.go index ed0be963..ce1cb774 100644 --- a/internal/cmd/sandbox.go +++ b/internal/cmd/sandbox.go @@ -29,13 +29,29 @@ var ( ) // sandboxCmd is the parent command for local sandbox helpers. -var sandboxCmd = &cobra.Command{ - Use: "sandbox", - Short: "Local sandbox utilities for simulated ledger state", - Long: `Manage a local \"sandbox\" overlay for simulations. +func NewSandboxCmd() *cobra.Command { + sandboxCmd := &cobra.Command{ + Use: "sandbox", + Short: "Local sandbox utilities for simulated ledger state", + Long: `Manage a local \"sandbox\" overlay for simulations. This command family operates purely on local override state and never submits transactions on-chain.`, + } + sandboxFundCmd.Flags().Uint64Var( + &sandboxAmountFlag, + "amount", + sandboxDefaultAmountXLM, + "Amount of native tokens (XLM) to mock-fund in the sandbox ledger", + ) + sandboxFundCmd.Flags().StringVar( + &sandboxStateFile, + "state-file", + sandboxDefaultStateFile, + "JSON override file to create/update for sandbox ledger state", + ) + sandboxCmd.AddCommand(sandboxFundCmd) + return sandboxCmd } // sandboxFundCmd implements: @@ -176,21 +192,3 @@ func writeSandboxFunding(account string, amountXLM uint64, path string) error { return nil } - -func init() { - sandboxFundCmd.Flags().Uint64Var( - &sandboxAmountFlag, - "amount", - sandboxDefaultAmountXLM, - "Amount of native tokens (XLM) to mock-fund in the sandbox ledger", - ) - sandboxFundCmd.Flags().StringVar( - &sandboxStateFile, - "state-file", - sandboxDefaultStateFile, - "JSON override file to create/update for sandbox ledger state", - ) - - sandboxCmd.AddCommand(sandboxFundCmd) - rootCmd.AddCommand(sandboxCmd) -} diff --git a/internal/cmd/search.go b/internal/cmd/search.go index c736dc88..1865950c 100644 --- a/internal/cmd/search.go +++ b/internal/cmd/search.go @@ -20,11 +20,12 @@ var ( searchRecentFlag bool ) -var searchCmd = &cobra.Command{ - Use: "search", - GroupID: "management", - Short: "Search through saved debugging sessions", - Long: `Search through the history of debugging sessions to find past transactions, +func NewSearchCmd() *cobra.Command { + searchCmd := &cobra.Command{ + Use: "search", + GroupID: "management", + Short: "Search through saved debugging sessions", + Long: `Search through the history of debugging sessions to find past transactions, errors, or events. Supports regex patterns for flexible matching. You can search by: @@ -34,7 +35,7 @@ You can search by: • Combine multiple filters Results are ordered by timestamp (most recent first) and limited by --limit flag.`, - Example: ` # Search for specific transaction + Example: ` # Search for specific transaction erst search --tx abc123...def789 # Find sessions with specific error patterns @@ -45,91 +46,88 @@ Results are ordered by timestamp (most recent first) and limited by --limit flag # Combine filters and limit results erst search --error "panic" --limit 5`, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - if searchRecentFlag { - uiStore, err := session.NewUIStateStore() - if err != nil { - return fmt.Errorf("failed to open viewer state: %w", err) + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if searchRecentFlag { + uiStore, err := session.NewUIStateStore() + if err != nil { + return fmt.Errorf("failed to open viewer state: %w", err) + } + defer uiStore.Close() + queries, err := uiStore.RecentSearches(cmd.Context(), 10) + if err != nil { + return fmt.Errorf("failed to load recent searches: %w", err) + } + if len(queries) == 0 { + fmt.Println("No recent searches.") + return nil + } + fmt.Printf("Recent searches (%d):\n", len(queries)) + for i, q := range queries { + fmt.Printf(" %d. %s\n", i+1, q) + } + return nil } - defer uiStore.Close() - queries, err := uiStore.RecentSearches(cmd.Context(), 10) + + store, err := db.InitDB() if err != nil { - return fmt.Errorf("failed to load recent searches: %w", err) + return errors.WrapValidationError(fmt.Sprintf("failed to initialize session database: %v", err)) } - if len(queries) == 0 { - fmt.Println("No recent searches.") - return nil + + params := db.SearchParams{ + TxHash: searchTxFlag, + ErrorRegex: searchErrorFlag, + EventRegex: searchEventFlag, + Limit: searchLimitFlag, } - fmt.Printf("Recent searches (%d):\n", len(queries)) - for i, q := range queries { - fmt.Printf(" %d. %s\n", i+1, q) + + sessions, err := store.SearchSessions(params) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("search failed: %v", err)) } - return nil - } - - store, err := db.InitDB() - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to initialize session database: %v", err)) - } - - params := db.SearchParams{ - TxHash: searchTxFlag, - ErrorRegex: searchErrorFlag, - EventRegex: searchEventFlag, - Limit: searchLimitFlag, - } - - sessions, err := store.SearchSessions(params) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("search failed: %v", err)) - } - - if len(sessions) == 0 { - fmt.Println("No matching sessions found.") - return nil - } - fmt.Printf("Found %d matching sessions:\n", len(sessions)) - for _, s := range sessions { - fmt.Println("--------------------------------------------------") - fmt.Printf("ID: %d\n", s.ID) - fmt.Printf("Time: %s\n", s.Timestamp.Format("2006-01-02 15:04:05")) - fmt.Printf("Tx Hash: %s\n", s.TxHash) - fmt.Printf("Network: %s\n", s.Network) - fmt.Printf("Status: %s\n", s.Status) - if s.ErrorMsg != "" { - fmt.Printf("Error: %s\n", s.ErrorMsg) + if len(sessions) == 0 { + fmt.Println("No matching sessions found.") + return nil } - if len(s.Events) > 0 { - fmt.Println("Events:") - for _, e := range s.Events { - fmt.Printf(" - %s\n", e) + + fmt.Printf("Found %d matching sessions:\n", len(sessions)) + for _, s := range sessions { + fmt.Println("--------------------------------------------------") + fmt.Printf("ID: %d\n", s.ID) + fmt.Printf("Time: %s\n", s.Timestamp.Format("2006-01-02 15:04:05")) + fmt.Printf("Tx Hash: %s\n", s.TxHash) + fmt.Printf("Network: %s\n", s.Network) + fmt.Printf("Status: %s\n", s.Status) + if s.ErrorMsg != "" { + fmt.Printf("Error: %s\n", s.ErrorMsg) } - } - } - fmt.Println("--------------------------------------------------") - - // Persist non-empty search terms for future recall (best-effort). - if uiStore, err := session.NewUIStateStore(); err == nil { - defer uiStore.Close() - for _, q := range []string{searchErrorFlag, searchEventFlag, searchTxFlag} { - if q != "" { - _ = uiStore.AppendRecentSearch(cmd.Context(), q) + if len(s.Events) > 0 { + fmt.Println("Events:") + for _, e := range s.Events { + fmt.Printf(" - %s\n", e) + } } } - } + fmt.Println("--------------------------------------------------") - return nil - }, -} + // Persist non-empty search terms for future recall (best-effort). + if uiStore, err := session.NewUIStateStore(); err == nil { + defer uiStore.Close() + for _, q := range []string{searchErrorFlag, searchEventFlag, searchTxFlag} { + if q != "" { + _ = uiStore.AppendRecentSearch(cmd.Context(), q) + } + } + } -func init() { + return nil + }, + } searchCmd.Flags().StringVar(&searchErrorFlag, "error", "", "Regex pattern to match error messages") searchCmd.Flags().StringVar(&searchEventFlag, "event", "", "Regex pattern to match events") searchCmd.Flags().StringVar(&searchTxFlag, "tx", "", "Transaction hash to search for") searchCmd.Flags().IntVar(&searchLimitFlag, "limit", 10, "Maximum number of results to return") searchCmd.Flags().BoolVar(&searchRecentFlag, "recent", false, "Show recent search queries") - - rootCmd.AddCommand(searchCmd) + return searchCmd } diff --git a/internal/cmd/session.go b/internal/cmd/session.go index deb14a68..60eb10ff 100644 --- a/internal/cmd/session.go +++ b/internal/cmd/session.go @@ -31,11 +31,12 @@ func GetCurrentSession() *session.SessionData { return currentSessionData } -var sessionCmd = &cobra.Command{ - Use: "session", - GroupID: "management", - Short: "Manage debugging sessions", - Long: `Save, resume, and manage debugging sessions to preserve state across CLI invocations. +func NewSessionCmd() *cobra.Command { + sessionCmd := &cobra.Command{ + Use: "session", + GroupID: "management", + Short: "Manage debugging sessions", + Long: `Save, resume, and manage debugging sessions to preserve state across CLI invocations. Sessions store complete transaction data, simulation results, and analysis context, allowing you to: @@ -49,7 +50,7 @@ Available subcommands: resume - Restore a saved session list - View all saved sessions delete - Remove a saved session`, - Example: ` # Save current debug session + Example: ` # Save current debug session erst session save # List all sessions @@ -60,6 +61,13 @@ Available subcommands: # Delete a session erst session delete `, + } + sessionSaveCmd.Flags().StringVar(&sessionIDFlag, "id", "", "Custom session ID (default: auto-generated)") + sessionCmd.AddCommand(sessionSaveCmd) + sessionCmd.AddCommand(sessionResumeCmd) + sessionCmd.AddCommand(sessionListCmd) + sessionCmd.AddCommand(sessionDeleteCmd) + return sessionCmd } var sessionSaveCmd = &cobra.Command{ @@ -301,14 +309,3 @@ Use 'erst session list' to see available sessions.`, return nil }, } - -func init() { - sessionSaveCmd.Flags().StringVar(&sessionIDFlag, "id", "", "Custom session ID (default: auto-generated)") - - sessionCmd.AddCommand(sessionSaveCmd) - sessionCmd.AddCommand(sessionResumeCmd) - sessionCmd.AddCommand(sessionListCmd) - sessionCmd.AddCommand(sessionDeleteCmd) - - rootCmd.AddCommand(sessionCmd) -} diff --git a/internal/cmd/shell.go b/internal/cmd/shell.go index 0b0785dc..06eec3ff 100644 --- a/internal/cmd/shell.go +++ b/internal/cmd/shell.go @@ -25,11 +25,12 @@ var ( shellInitState string ) -var shellCmd = &cobra.Command{ - Use: "shell", - GroupID: "development", - Short: "Start an interactive shell for contract invocations", - Long: `Start a persistent interactive shell where you can invoke multiple contracts +func NewShellCmd() *cobra.Command { + shellCmd := &cobra.Command{ + Use: "shell", + GroupID: "development", + Short: "Start an interactive shell for contract invocations", + Long: `Start a persistent interactive shell where you can invoke multiple contracts consecutively without losing the local ledger state between commands. The shell maintains a stateful ledger that persists across invocations, allowing @@ -48,26 +49,23 @@ Shell Commands: state reset Reset to initial state help Show available commands exit Exit the shell`, - Args: cobra.NoArgs, - PreRunE: func(cmd *cobra.Command, args []string) error { - // Validate network flag - switch rpc.Network(shellNetworkFlag) { - case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: - return nil - default: - return errors.WrapInvalidNetwork(shellNetworkFlag) - } - }, - RunE: runShell, -} - -func init() { + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + // Validate network flag + switch rpc.Network(shellNetworkFlag) { + case rpc.Testnet, rpc.Mainnet, rpc.Futurenet: + return nil + default: + return errors.WrapInvalidNetwork(shellNetworkFlag) + } + }, + RunE: runShell, + } shellCmd.Flags().StringVarP(&shellNetworkFlag, "network", "n", string(rpc.Testnet), "Stellar network to use (testnet, mainnet, futurenet)") shellCmd.Flags().StringVar(&shellRPCURLFlag, "rpc-url", "", "Custom Horizon RPC URL to use") shellCmd.Flags().StringVar(&shellRPCToken, "rpc-token", "", "RPC authentication token") shellCmd.Flags().StringVar(&shellInitState, "init-state", "", "Initial ledger state file (JSON)") - - rootCmd.AddCommand(shellCmd) + return shellCmd } func runShell(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/snapshot_diff.go b/internal/cmd/snapshot_diff.go index bc96a526..f4c13200 100644 --- a/internal/cmd/snapshot_diff.go +++ b/internal/cmd/snapshot_diff.go @@ -21,11 +21,12 @@ var ( snapshotDiffContextFlag int ) -var snapshotDiffCmd = &cobra.Command{ - Use: "snapshot-diff", - GroupID: "utility", - Short: "Compare linear memory between two snapshots", - Long: `Compare linear memory dumps from two snapshot files (Snapshot A and Snapshot B) +func NewSnapshotDiffCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "snapshot-diff", + GroupID: "utility", + Short: "Compare linear memory between two snapshots", + Long: `Compare linear memory dumps from two snapshot files (Snapshot A and Snapshot B) and display the differences in a side-by-side HEX/ASCII format. This command is useful for debugging state-related bugs by seeing how memory @@ -40,16 +41,14 @@ Examples: # Show more context around changes erst snapshot-diff --snapshot-a before.json --snapshot-b after.json --context 32`, - RunE: runSnapshotDiff, -} - -func init() { - snapshotDiffCmd.Flags().StringVar(&snapshotDiffAFlag, "snapshot-a", "", "First snapshot file (Snapshot A)") - snapshotDiffCmd.Flags().StringVar(&snapshotDiffBFlag, "snapshot-b", "", "Second snapshot file (Snapshot B)") - snapshotDiffCmd.Flags().IntVar(&snapshotDiffOffsetFlag, "offset", -1, "Start offset to compare (default: compare all)") - snapshotDiffCmd.Flags().IntVar(&snapshotDiffLengthFlag, "length", 0, "Number of bytes to compare (default: all from offset)") - snapshotDiffCmd.Flags().IntVar(&snapshotDiffContextFlag, "context", 16, "Bytes of context to show around changes") - rootCmd.AddCommand(snapshotDiffCmd) + RunE: runSnapshotDiff, + } + cmd.Flags().StringVar(&snapshotDiffAFlag, "snapshot-a", "", "First snapshot file (Snapshot A)") + cmd.Flags().StringVar(&snapshotDiffBFlag, "snapshot-b", "", "Second snapshot file (Snapshot B)") + cmd.Flags().IntVar(&snapshotDiffOffsetFlag, "offset", -1, "Start offset to compare (default: compare all)") + cmd.Flags().IntVar(&snapshotDiffLengthFlag, "length", 0, "Number of bytes to compare (default: all from offset)") + cmd.Flags().IntVar(&snapshotDiffContextFlag, "context", 16, "Bytes of context to show around changes") + return cmd } func runSnapshotDiff(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/snapshot_memory.go b/internal/cmd/snapshot_memory.go index 3d9a74e6..72ca9f58 100644 --- a/internal/cmd/snapshot_memory.go +++ b/internal/cmd/snapshot_memory.go @@ -18,47 +18,53 @@ var ( snapshotMemoryLengthFlag int ) -var snapshotMemoryCmd = &cobra.Command{ - Use: "snapshot-memory", - GroupID: "utility", - Short: "Decode and inspect linear memory from a snapshot", - Long: `Decode a snapshot's base64 memory dump and print human-readable segments.`, - RunE: func(cmd *cobra.Command, args []string) error { - if snapshotMemoryFileFlag == "" { - return errors.WrapCliArgumentRequired("snapshot") - } +func NewSnapshotMemoryCmd() *cobra.Command { + snapshotMemoryCmd := &cobra.Command{ + Use: "snapshot-memory", + GroupID: "utility", + Short: "Decode and inspect linear memory from a snapshot", + Long: `Decode a snapshot's base64 memory dump and print human-readable segments.`, + RunE: func(cmd *cobra.Command, args []string) error { + if snapshotMemoryFileFlag == "" { + return errors.WrapCliArgumentRequired("snapshot") + } - snap, err := snapshot.Load(snapshotMemoryFileFlag) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to load snapshot: %v", err)) - } + snap, err := snapshot.Load(snapshotMemoryFileFlag) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to load snapshot: %v", err)) + } - mem, err := snap.DecodeLinearMemory() - if err != nil { - return errors.WrapValidationError(err.Error()) - } - if len(mem) == 0 { - return errors.WrapValidationError("snapshot does not contain linear memory dump") - } + mem, err := snap.DecodeLinearMemory() + if err != nil { + return errors.WrapValidationError(err.Error()) + } + if len(mem) == 0 { + return errors.WrapValidationError("snapshot does not contain linear memory dump") + } - if snapshotMemoryOffsetFlag < 0 || snapshotMemoryOffsetFlag > len(mem) { - return errors.WrapValidationError("offset out of bounds") - } + if snapshotMemoryOffsetFlag < 0 || snapshotMemoryOffsetFlag > len(mem) { + return errors.WrapValidationError("offset out of bounds") + } - length := snapshotMemoryLengthFlag - if length <= 0 { - length = 256 - } - end := snapshotMemoryOffsetFlag + length - if end > len(mem) { - end = len(mem) - } + length := snapshotMemoryLengthFlag + if length <= 0 { + length = 256 + } + end := snapshotMemoryOffsetFlag + length + if end > len(mem) { + end = len(mem) + } - fmt.Printf("Linear memory bytes: %d\n", len(mem)) - fmt.Printf("Showing range [%d:%d]\n", snapshotMemoryOffsetFlag, end) - printMemorySegment(mem[snapshotMemoryOffsetFlag:end], snapshotMemoryOffsetFlag) - return nil - }, + fmt.Printf("Linear memory bytes: %d\n", len(mem)) + fmt.Printf("Showing range [%d:%d]\n", snapshotMemoryOffsetFlag, end) + printMemorySegment(mem[snapshotMemoryOffsetFlag:end], snapshotMemoryOffsetFlag) + return nil + }, + } + snapshotMemoryCmd.Flags().StringVar(&snapshotMemoryFileFlag, "snapshot", "", "Snapshot JSON file to inspect") + snapshotMemoryCmd.Flags().IntVar(&snapshotMemoryOffsetFlag, "offset", 0, "Byte offset to start printing") + snapshotMemoryCmd.Flags().IntVar(&snapshotMemoryLengthFlag, "length", 256, "Number of bytes to print") + return snapshotMemoryCmd } func printMemorySegment(data []byte, baseOffset int) { @@ -87,10 +93,3 @@ func printMemorySegment(data []byte, baseOffset int) { fmt.Printf("%08x %s |%s|\n", baseOffset+i, strings.Join(hexParts, " "), string(ascii)) } } - -func init() { - snapshotMemoryCmd.Flags().StringVar(&snapshotMemoryFileFlag, "snapshot", "", "Snapshot JSON file to inspect") - snapshotMemoryCmd.Flags().IntVar(&snapshotMemoryOffsetFlag, "offset", 0, "Byte offset to start printing") - snapshotMemoryCmd.Flags().IntVar(&snapshotMemoryLengthFlag, "length", 256, "Number of bytes to print") - rootCmd.AddCommand(snapshotMemoryCmd) -} diff --git a/internal/cmd/stats.go b/internal/cmd/stats.go index ca2f63b3..53f9fd51 100644 --- a/internal/cmd/stats.go +++ b/internal/cmd/stats.go @@ -34,11 +34,12 @@ type contractStat struct { seenTypes map[string]bool } -var statsCmd = &cobra.Command{ - Use: "stats", - GroupID: "utility", - Short: "Summarize budget usage and call depth for the top contract calls", - Long: `Returns a non-interactive table of the top 5 most expensive contract calls. +func NewStatsCmd() *cobra.Command { + statsCmd := &cobra.Command{ + Use: "stats", + GroupID: "utility", + Short: "Summarize budget usage and call depth for the top contract calls", + Long: `Returns a non-interactive table of the top 5 most expensive contract calls. Cost is estimated based on weighted operations: - Storage writes: weight 3 @@ -46,8 +47,11 @@ Cost is estimated based on weighted operations: - Other events: weight 1 Call depth counts the number of distinct event types observed per contract.`, - Args: cobra.NoArgs, - RunE: runStats, + Args: cobra.NoArgs, + RunE: runStats, + } + statsCmd.Flags().StringVar(&statsSessionFlag, "session", "", "Load a saved session by ID") + return statsCmd } func runStats(cmd *cobra.Command, args []string) error { @@ -170,8 +174,3 @@ func printStatsTable(stats []contractStat) { fmt.Printf("%d. %-41s | %-12d | %-7d\n", i+1, displayID, s.estimatedCost, s.callDepth) } } - -func init() { - statsCmd.Flags().StringVar(&statsSessionFlag, "session", "", "Load a saved session by ID") - rootCmd.AddCommand(statsCmd) -} diff --git a/internal/cmd/status.go b/internal/cmd/status.go index 21671fda..307ef359 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -19,11 +19,12 @@ import ( var statusFixFlag bool -var statusCmd = &cobra.Command{ - Use: "status", - GroupID: "utility", - Short: "Check protocol registration and system health", - Long: `Inspect the health of the erst:// protocol handler registration. +func NewStatusCmd() *cobra.Command { + statusCmd := &cobra.Command{ + Use: "status", + GroupID: "utility", + Short: "Check protocol registration and system health", + Long: `Inspect the health of the erst:// protocol handler registration. This command verifies that the custom URI scheme (erst://) is properly registered with the operating system so that deep links work correctly. @@ -34,13 +35,16 @@ On failure it can interactively offer to repair the registration: - Linux: rewrites ~/.local/share/applications/erst-protocol.desktop Use --fix to skip the interactive prompt and repair automatically.`, - Example: ` # Check protocol registration status + Example: ` # Check protocol registration status erst status # Automatically repair without prompting erst status --fix`, - Args: cobra.NoArgs, - RunE: runStatus, + Args: cobra.NoArgs, + RunE: runStatus, + } + statusCmd.Flags().BoolVar(&statusFixFlag, "fix", false, "Automatically repair broken protocol registration without prompting") + return statusCmd } func runStatus(cmd *cobra.Command, args []string) error { @@ -337,8 +341,3 @@ func promptYesNo(cmd *cobra.Command, prompt string) bool { answer := strings.TrimSpace(strings.ToLower(input)) return answer == "y" || answer == "yes" } - -func init() { - statusCmd.Flags().BoolVar(&statusFixFlag, "fix", false, "Automatically repair broken protocol registration without prompting") - rootCmd.AddCommand(statusCmd) -} diff --git a/internal/cmd/status_test.go b/internal/cmd/status_test.go index fb99aa6a..465a3d12 100644 --- a/internal/cmd/status_test.go +++ b/internal/cmd/status_test.go @@ -13,6 +13,7 @@ import ( ) func TestStatusCommandRegistered(t *testing.T) { + statusCmd := NewStatusCmd() if statusCmd == nil { t.Fatal("statusCmd should not be nil") } @@ -25,6 +26,7 @@ func TestStatusCommandRegistered(t *testing.T) { } func TestStatusFixFlag(t *testing.T) { + statusCmd := NewStatusCmd() flag := statusCmd.Flags().Lookup("fix") if flag == nil { t.Fatal("status command should have --fix flag") @@ -134,7 +136,7 @@ func TestPromptYesNo(t *testing.T) { for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { - cmd := statusCmd + cmd := NewStatusCmd() cmd.SetIn(strings.NewReader(tt.input)) cmd.SetOut(&bytes.Buffer{}) @@ -206,7 +208,7 @@ func TestRepairProtocolLinux(t *testing.T) { func TestStatusOutputRegistered(t *testing.T) { // Run the status command and capture output — just verify it doesn't panic var buf bytes.Buffer - cmd := statusCmd + cmd := NewStatusCmd() cmd.SetOut(&buf) cmd.SetErr(&buf) cmd.SetIn(strings.NewReader("n\n")) // answer "no" to any prompt diff --git a/internal/cmd/trace.go b/internal/cmd/trace.go index 93386589..25c03b61 100644 --- a/internal/cmd/trace.go +++ b/internal/cmd/trace.go @@ -20,11 +20,12 @@ var ( traceNoColor bool ) -var traceCmd = &cobra.Command{ - Use: "trace ", - GroupID: "core", - Short: "Interactive trace navigation and debugging", - Long: `Launch an interactive trace viewer for bi-directional navigation through execution traces. +func NewTraceCmd() *cobra.Command { + traceCmd := &cobra.Command{ + Use: "trace ", + GroupID: "core", + Short: "Interactive trace navigation and debugging", + Long: `Launch an interactive trace viewer for bi-directional navigation through execution traces. The trace viewer allows you to: - Step forward and backward through execution @@ -40,62 +41,58 @@ Example: erst trace --file debug_trace.json erst trace --print execution.json erst trace --print --no-color execution.json | less`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - // Apply theme if specified, otherwise auto-detect - if traceThemeFlag != "" { - visualizer.SetTheme(visualizer.Theme(traceThemeFlag)) - } else { - visualizer.SetTheme(visualizer.DetectTheme()) - } - - var filename string - if len(args) > 0 { - filename = args[0] - } else if traceFile != "" { - filename = traceFile - } else { - return errors.WrapCliArgumentRequired("file") - } + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // Apply theme if specified, otherwise auto-detect + if traceThemeFlag != "" { + visualizer.SetTheme(visualizer.Theme(traceThemeFlag)) + } else { + visualizer.SetTheme(visualizer.DetectTheme()) + } - // Check if file exists - if _, err := os.Stat(filename); os.IsNotExist(err) { - return errors.WrapValidationError(fmt.Sprintf("trace file not found: %s", filename)) - } + var filename string + if len(args) > 0 { + filename = args[0] + } else if traceFile != "" { + filename = traceFile + } else { + return errors.WrapCliArgumentRequired("file") + } - // Load trace from file - data, err := os.ReadFile(filename) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to read trace file: %v", err)) - } + // Check if file exists + if _, err := os.Stat(filename); os.IsNotExist(err) { + return errors.WrapValidationError(fmt.Sprintf("trace file not found: %s", filename)) + } - executionTrace, err := trace.FromJSON(data) - if err != nil { - return errors.WrapUnmarshalFailed(err, "trace") - } + // Load trace from file + data, err := os.ReadFile(filename) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to read trace file: %v", err)) + } - // --print: render a rich ASCII tree report then exit (non-interactive) - if tracePrint { - opts := trace.PrintOptions{ - NoColor: traceNoColor, + executionTrace, err := trace.FromJSON(data) + if err != nil { + return errors.WrapUnmarshalFailed(err, "trace") } - trace.PrintExecutionTrace(executionTrace, opts) - return nil - } - // Start interactive viewer - viewer := trace.NewInteractiveViewer(executionTrace) - return viewer.Start() - }, -} + // --print: render a rich ASCII tree report then exit (non-interactive) + if tracePrint { + opts := trace.PrintOptions{ + NoColor: traceNoColor, + } + trace.PrintExecutionTrace(executionTrace, opts) + return nil + } -func init() { + // Start interactive viewer + viewer := trace.NewInteractiveViewer(executionTrace) + return viewer.Start() + }, + } traceCmd.Flags().StringVarP(&traceFile, "file", "f", "", "Trace file to load") traceCmd.Flags().StringVar(&traceThemeFlag, "theme", "", "Color theme (default, deuteranopia, protanopia, tritanopia, high-contrast)") traceCmd.Flags().BoolVar(&tracePrint, "print", false, "Print a rich ASCII tree report and exit (non-interactive)") traceCmd.Flags().BoolVar(&traceNoColor, "no-color", false, "Disable ANSI colour output (also honoured via NO_COLOR env var)") - _ = traceCmd.RegisterFlagCompletionFunc("theme", completeThemeFlag) - - rootCmd.AddCommand(traceCmd) + return traceCmd } diff --git a/internal/cmd/upgrade.go b/internal/cmd/upgrade.go index 318ba97a..268544ee 100644 --- a/internal/cmd/upgrade.go +++ b/internal/cmd/upgrade.go @@ -21,110 +21,109 @@ var ( upgradeOptimizeFlag bool ) -var upgradeCmd = &cobra.Command{ - Use: "simulate-upgrade --new-wasm ", - GroupID: "utility", - Short: "Simulate a transaction with upgraded contract code", - Long: `Replay a transaction but replace the contract code with a new WASM file. +func NewUpgradeCmd() *cobra.Command { + upgradeCmd := &cobra.Command{ + Use: "simulate-upgrade --new-wasm ", + GroupID: "utility", + Short: "Simulate a transaction with upgraded contract code", + Long: `Replay a transaction but replace the contract code with a new WASM file. This allows verifying if a planned upgrade will break existing functionality. Example: erst simulate-upgrade 5c0a... --new-wasm ./new_v2.wasm --network mainnet`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - txHash := args[0] + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + txHash := args[0] - if newWasmPath == "" { - return errors.WrapCliArgumentRequired("new-wasm") - } - - // 1. Read New WASM - newWasmBytes, err := os.ReadFile(newWasmPath) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to read WASM file: %v", err)) - } - optimizedWasmBytes, report, err := optimizeWasmBytesIfRequested(newWasmBytes, upgradeOptimizeFlag) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to optimize WASM: %v", err)) - } - newWasmBytes = optimizedWasmBytes - fmt.Printf("Loaded new WASM code: %d bytes\n", len(newWasmBytes)) - if upgradeOptimizeFlag { - printOptimizationReport(report) - } + if newWasmPath == "" { + return errors.WrapCliArgumentRequired("new-wasm") + } - // 2. Setup Client - opts := []rpc.ClientOption{ - rpc.WithNetwork(rpc.Network(networkFlag)), - } - if rpcURLFlag != "" { - opts = append(opts, rpc.WithHorizonURL(rpcURLFlag)) - } + // 1. Read New WASM + newWasmBytes, err := os.ReadFile(newWasmPath) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to read WASM file: %v", err)) + } + optimizedWasmBytes, report, err := optimizeWasmBytesIfRequested(newWasmBytes, upgradeOptimizeFlag) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to optimize WASM: %v", err)) + } + newWasmBytes = optimizedWasmBytes + fmt.Printf("Loaded new WASM code: %d bytes\n", len(newWasmBytes)) + if upgradeOptimizeFlag { + printOptimizationReport(report) + } - client, err := rpc.NewClient(opts...) - if err != nil { - return errors.WrapValidationError(fmt.Sprintf("failed to create client: %v", err)) - } - registerCacheFlushHook() + // 2. Setup Client + opts := []rpc.ClientOption{ + rpc.WithNetwork(rpc.Network(networkFlag)), + } + if rpcURLFlag != "" { + opts = append(opts, rpc.WithHorizonURL(rpcURLFlag)) + } - // 3. Fetch Transaction - fmt.Printf("Fetching transaction: %s from %s\n", txHash, networkFlag) - resp, err := client.GetTransaction(cmd.Context(), txHash) - if err != nil { - return errors.WrapRPCConnectionFailed(err) - } + client, err := rpc.NewClient(opts...) + if err != nil { + return errors.WrapValidationError(fmt.Sprintf("failed to create client: %v", err)) + } + registerCacheFlushHook() - // 4. Extract Keys & Fetch State - keys, err := extractLedgerKeys(resp.ResultMetaXdr) - if err != nil { - return errors.WrapUnmarshalFailed(err, "result meta") - } + // 3. Fetch Transaction + fmt.Printf("Fetching transaction: %s from %s\n", txHash, networkFlag) + resp, err := client.GetTransaction(cmd.Context(), txHash) + if err != nil { + return errors.WrapRPCConnectionFailed(err) + } - entries, err := client.GetLedgerEntries(cmd.Context(), keys) - if err != nil { - return errors.WrapRPCConnectionFailed(err) - } - fmt.Printf("Fetched %d ledger entries\n", len(entries)) + // 4. Extract Keys & Fetch State + keys, err := extractLedgerKeys(resp.ResultMetaXdr) + if err != nil { + return errors.WrapUnmarshalFailed(err, "result meta") + } - // 5. Identify Contract ID and Inject New Code - contractID, err := getContractIDFromEnvelope(resp.EnvelopeXdr) - if err != nil { - return errors.WrapSimulationLogicError(fmt.Sprintf("failed to identify contract from transaction: %v", err)) - } - fmt.Printf("Identified target contract: %x\n", *contractID) + entries, err := client.GetLedgerEntries(cmd.Context(), keys) + if err != nil { + return errors.WrapRPCConnectionFailed(err) + } + fmt.Printf("Fetched %d ledger entries\n", len(entries)) - if injectErr := injectNewCode(entries, *contractID, newWasmBytes); injectErr != nil { - return errors.WrapSimulationLogicError(fmt.Sprintf("failed to inject new code: %v", injectErr)) - } - fmt.Println("Injected new WASM code into simulation state.") + // 5. Identify Contract ID and Inject New Code + contractID, err := getContractIDFromEnvelope(resp.EnvelopeXdr) + if err != nil { + return errors.WrapSimulationLogicError(fmt.Sprintf("failed to identify contract from transaction: %v", err)) + } + fmt.Printf("Identified target contract: %x\n", *contractID) - // 6. Run Simulation - runner, err := simulator.NewRunner("", false) - if err != nil { - return errors.WrapSimulatorNotFound(err.Error()) - } - registerRunnerCloseHook("upgrade-simulator-runner", runner) - defer func() { _ = runner.Close() }() + if injectErr := injectNewCode(entries, *contractID, newWasmBytes); injectErr != nil { + return errors.WrapSimulationLogicError(fmt.Sprintf("failed to inject new code: %v", injectErr)) + } + fmt.Println("Injected new WASM code into simulation state.") - simReq := &simulator.SimulationRequest{ - EnvelopeXdr: resp.EnvelopeXdr, - ResultMetaXdr: resp.ResultMetaXdr, - LedgerEntries: entries, - } + // 6. Run Simulation + runner, err := simulator.NewRunner("", false) + if err != nil { + return errors.WrapSimulatorNotFound(err.Error()) + } + registerRunnerCloseHook("upgrade-simulator-runner", runner) + defer func() { _ = runner.Close() }() - fmt.Println("Running simulation with upgraded code...") - result, err := runner.Run(cmd.Context(), simReq) - if err != nil { - return errors.WrapSimulationFailed(err, "") - } + simReq := &simulator.SimulationRequest{ + EnvelopeXdr: resp.EnvelopeXdr, + ResultMetaXdr: resp.ResultMetaXdr, + LedgerEntries: entries, + } - printSimulationResult("Upgraded Contract", result) + fmt.Println("Running simulation with upgraded code...") + result, err := runner.Run(cmd.Context(), simReq) + if err != nil { + return errors.WrapSimulationFailed(err, "") + } - return nil - }, -} + printSimulationResult("Upgraded Contract", result) -func init() { + return nil + }, + } upgradeCmd.Flags().StringVar(&newWasmPath, "new-wasm", "", "Path to the new WASM file") upgradeCmd.Flags().BoolVar(&upgradeOptimizeFlag, "optimize", false, "Run dead-code elimination on the new WASM before simulation") // Reuse network flags from debug.go if possible, but they are var blocks there. @@ -132,10 +131,8 @@ func init() { // BUT we need to register flags for THIS command too. upgradeCmd.Flags().StringVarP(&networkFlag, "network", "n", string(rpc.Mainnet), "Stellar network to use") upgradeCmd.Flags().StringVar(&rpcURLFlag, "rpc-url", "", "Custom Horizon RPC URL") - _ = upgradeCmd.RegisterFlagCompletionFunc("network", completeNetworkFlag) - - rootCmd.AddCommand(upgradeCmd) + return upgradeCmd } func getContractIDFromEnvelope(envelopeXdr string) (*xdr.Hash, error) { diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 9cfe69f8..2a26dcbc 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -27,28 +27,32 @@ type VersionInfo struct { } // versionCmd represents the version command -var versionCmd = &cobra.Command{ - Use: "version", - GroupID: "utility", - Short: "Show version information", - Long: "Display detailed build information including version, commit hash, and build date", - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - jsonOutput, _ := cmd.Flags().GetBool("json") +func NewVersionCmd() *cobra.Command { + versionCmd := &cobra.Command{ + Use: "version", + GroupID: "utility", + Short: "Show version information", + Long: "Display detailed build information including version, commit hash, and build date", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + jsonOutput, _ := cmd.Flags().GetBool("json") - info := getVersionInfo() + info := getVersionInfo() - if jsonOutput { - output, _ := json.MarshalIndent(info, "", " ") - fmt.Println(string(output)) - } else { - fmt.Printf("Erst Version: %s\n", info.Version) - fmt.Printf("Commit SHA: %s\n", info.CommitSHA) - fmt.Printf("Build Date: %s\n", info.BuildDate) - fmt.Printf("Go Version: %s\n", info.GoVersion) - } - fmt.Printf("erst version %s\n", Version) - }, + if jsonOutput { + output, _ := json.MarshalIndent(info, "", " ") + fmt.Println(string(output)) + } else { + fmt.Printf("Erst Version: %s\n", info.Version) + fmt.Printf("Commit SHA: %s\n", info.CommitSHA) + fmt.Printf("Build Date: %s\n", info.BuildDate) + fmt.Printf("Go Version: %s\n", info.GoVersion) + } + fmt.Printf("erst version %s\n", Version) + }, + } + versionCmd.Flags().Bool("json", false, "Output version information in JSON format") + return versionCmd } func getVersionInfo() VersionInfo { @@ -82,8 +86,3 @@ func getVersionInfo() VersionInfo { return info } - -func init() { - rootCmd.AddCommand(versionCmd) - versionCmd.Flags().Bool("json", false, "Output version information in JSON format") -} diff --git a/internal/cmd/wizard.go b/internal/cmd/wizard.go index c4672bc5..cdbab0f3 100644 --- a/internal/cmd/wizard.go +++ b/internal/cmd/wizard.go @@ -12,41 +12,38 @@ import ( "github.com/spf13/cobra" ) -var wizardCmd = &cobra.Command{ - Use: "wizard", - GroupID: "development", - Short: "Interactive transaction selection wizard", - Long: "Find and select recent failed transactions for debugging.", - RunE: func(cmd *cobra.Command, args []string) error { - account, _ := cmd.Flags().GetString("account") - network, _ := cmd.Flags().GetString("network") - - if account == "" { - return errors.WrapCliArgumentRequired("account") - } - - client, err := rpc.NewClient(rpc.WithNetwork(rpc.Network(network))) - if err != nil { - return errors.WrapValidationError(err.Error()) - } - - w := wizard.New(client) - result, err := w.SelectTransaction(cmd.Context(), account) - if err != nil { - return err - } - - fmt.Printf("\nSelected: %s\nStatus: %s\nCreated: %s\n\nRun: erst debug %s\n", - result.Hash, result.Status, result.CreatedAt, result.Hash) - return nil - }, -} - -func init() { +func NewWizardCmd() *cobra.Command { + wizardCmd := &cobra.Command{ + Use: "wizard", + GroupID: "development", + Short: "Interactive transaction selection wizard", + Long: "Find and select recent failed transactions for debugging.", + RunE: func(cmd *cobra.Command, args []string) error { + account, _ := cmd.Flags().GetString("account") + network, _ := cmd.Flags().GetString("network") + + if account == "" { + return errors.WrapCliArgumentRequired("account") + } + + client, err := rpc.NewClient(rpc.WithNetwork(rpc.Network(network))) + if err != nil { + return errors.WrapValidationError(err.Error()) + } + + w := wizard.New(client) + result, err := w.SelectTransaction(cmd.Context(), account) + if err != nil { + return err + } + + fmt.Printf("\nSelected: %s\nStatus: %s\nCreated: %s\n\nRun: erst debug %s\n", + result.Hash, result.Status, result.CreatedAt, result.Hash) + return nil + }, + } wizardCmd.Flags().StringP("account", "a", "", "Stellar account address") wizardCmd.Flags().StringP("network", "n", string(rpc.Mainnet), "Network (testnet, mainnet, futurenet)") - _ = wizardCmd.RegisterFlagCompletionFunc("network", completeNetworkFlag) - - rootCmd.AddCommand(wizardCmd) + return wizardCmd } diff --git a/internal/cmd/xdr.go b/internal/cmd/xdr.go index 6aef4bfb..eabfc159 100644 --- a/internal/cmd/xdr.go +++ b/internal/cmd/xdr.go @@ -18,12 +18,21 @@ var ( xdrType string ) -var xdrCmd = &cobra.Command{ - Use: "xdr", - GroupID: "utility", - Short: "Format and decode XDR data", - Long: `Decode and format XDR structures to JSON or table format for easy inspection.`, - RunE: xdrExec, +func NewXdrCmd() *cobra.Command { + xdrCmd := &cobra.Command{ + Use: "xdr", + GroupID: "utility", + Short: "Format and decode XDR data", + Long: `Decode and format XDR structures to JSON or table format for easy inspection.`, + RunE: xdrExec, + } + xdrCmd.Flags().StringVar(&xdrData, "data", "", "Base64-encoded XDR data to decode") + xdrCmd.Flags().StringVar(&xdrFormat, "format", "json", "Output format: json or table") + xdrCmd.Flags().StringVar(&xdrType, "type", "ledger-entry", "XDR type: ledger-entry, diagnostic-event") + _ = xdrCmd.MarkFlagRequired("data") + _ = xdrCmd.RegisterFlagCompletionFunc("format", completeXDRFormatFlag) + _ = xdrCmd.RegisterFlagCompletionFunc("type", completeXDRTypeFlag) + return xdrCmd } func xdrExec(cmd *cobra.Command, args []string) error { @@ -66,16 +75,3 @@ func xdrExec(cmd *cobra.Command, args []string) error { fmt.Println(result) return nil } - -func init() { - rootCmd.AddCommand(xdrCmd) - - xdrCmd.Flags().StringVar(&xdrData, "data", "", "Base64-encoded XDR data to decode") - xdrCmd.Flags().StringVar(&xdrFormat, "format", "json", "Output format: json or table") - xdrCmd.Flags().StringVar(&xdrType, "type", "ledger-entry", "XDR type: ledger-entry, diagnostic-event") - - _ = xdrCmd.MarkFlagRequired("data") - - _ = xdrCmd.RegisterFlagCompletionFunc("format", completeXDRFormatFlag) - _ = xdrCmd.RegisterFlagCompletionFunc("type", completeXDRTypeFlag) -} diff --git a/internal/debug/registry.go b/internal/debug/registry.go new file mode 100644 index 00000000..f6c7090b --- /dev/null +++ b/internal/debug/registry.go @@ -0,0 +1,84 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +// Package debug provides utilities for inspecting and replaying session state, +// including a centralized registry for snapshots fetched during a session. +package debug + +import ( + "sync" + + "github.com/dotandev/hintents/internal/snapshot" +) + +// SnapshotRegistry defines a centralized store for snapshots fetched during a +// session. Implementations must be safe for concurrent use. +type SnapshotRegistry interface { + // Add stores a snapshot under the given id, overwriting any existing entry. + Add(id string, snap *snapshot.Snapshot) + + // Get retrieves the snapshot associated with id. + // Returns the snapshot and true if found, or nil and false otherwise. + Get(id string) (*snapshot.Snapshot, bool) + + // ListIDs returns the ids of all stored snapshots in insertion order. + ListIDs() []string + + // Clear removes all snapshots from the registry. + Clear() +} + +// snapshotRegistry is the default in-memory implementation of SnapshotRegistry. +type snapshotRegistry struct { + mu sync.RWMutex + entries map[string]*snapshot.Snapshot + ordering []string +} + +// NewSnapshotRegistry returns a new, empty SnapshotRegistry backed by an +// in-memory store. +func NewSnapshotRegistry() SnapshotRegistry { + return &snapshotRegistry{ + entries: make(map[string]*snapshot.Snapshot), + } +} + +// Add stores snap under id. If id already exists, the snapshot is replaced and +// its position in the ordering is unchanged. +func (r *snapshotRegistry) Add(id string, snap *snapshot.Snapshot) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.entries[id]; !exists { + r.ordering = append(r.ordering, id) + } + r.entries[id] = snap +} + +// Get retrieves the snapshot stored under id. +func (r *snapshotRegistry) Get(id string) (*snapshot.Snapshot, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + snap, ok := r.entries[id] + return snap, ok +} + +// ListIDs returns all stored ids in insertion order. +func (r *snapshotRegistry) ListIDs() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + ids := make([]string, len(r.ordering)) + copy(ids, r.ordering) + return ids +} + +// Clear removes all snapshots and resets the registry to an empty state. +func (r *snapshotRegistry) Clear() { + r.mu.Lock() + defer r.mu.Unlock() + + r.entries = make(map[string]*snapshot.Snapshot) + r.ordering = r.ordering[:0] +}