Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 12 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ The official command-line client and interactive explorer for the [Etherscan V2

- **Explore interactively** — browse endpoints, fill parameters, switch chains, and inspect results without memorizing commands.
- **Use one multichain interface** — query Ethereum and supported EVM chains by name or chain ID.
- **Work with humans or machines** — read tables in a terminal or emit clean JSON and CSV for automation.
- **Work with humans or machines** — emit clean JSON by default for scripts and agents, or switch to tables and CSV when a human or a spreadsheet is reading.
- **Reach broad API coverage** — access accounts, contracts, tokens, logs, blocks, gas, stats, name tags, and proxy methods, with automatic pagination for list endpoints.

## Install
Expand Down Expand Up @@ -134,19 +134,19 @@ The explorer can be opened before authentication and asks you to validate and sa
# Recent normal transactions
etherscan account txlist 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --page 1 --offset 10

# ERC-20 transfers as JSON
etherscan account tokentx 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --json
# ERC-20 transfers as a readable table instead of the default JSON
etherscan account tokentx 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 -o table

# Collect multiple pages for analysis
etherscan account txlist 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --all --max-pages 50 --csv
etherscan account txlist 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --all --max-pages 50 -o csv
```

### Inspect a smart contract

```sh
# WETH contract ABI and verified source metadata
etherscan contract getabi 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
etherscan contract getsourcecode 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 --json
etherscan contract getsourcecode 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
```

### Switch chains
Expand All @@ -157,29 +157,27 @@ etherscan --chain base gastracker oracle
etherscan --chain 8453 account balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045

# See every chain built into this release
etherscan chains list
etherscan chains
```

### Build scripts and agent workflows

```sh
# Clean JSON for jq, Python, or an AI agent
etherscan account txlist 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --json --compact | jq '.[0]'
etherscan account txlist 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --compact | jq '.[0]'

# CSV for spreadsheets and data pipelines
etherscan account tokentx 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --csv > token-transfers.csv
etherscan account tokentx 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 -o csv > token-transfers.csv
```

## Output and Pagination

Tables are the default. API results are written to stdout; progress messages, warnings, diagnostics, and errors go to stderr so stdout remains suitable for pipelines and redirection.
JSON is the default, matching the Etherscan API's own `application/json` responses. Use `-o table` for a readable terminal view of row-shaped results. API results are written to stdout; progress messages, warnings, diagnostics, and errors go to stderr so stdout remains suitable for pipelines and redirection.

| Flag | Purpose |
| --- | --- |
| `--output <format>`, `-o <format>` | Select `table`, `json`, or `csv` |
| `--json` | Print the raw API result as JSON |
| `--output <format>`, `-o <format>` | Select `json` (default), `table`, or `csv` |
| `--compact` | Remove indentation from JSON output |
| `--csv` | Print list-style results as CSV |
| `--all` | Automatically follow pages for supported list commands |
| `--max-pages <n>` | Stop `--all` after at most `n` pages (default: `20`) |

Expand Down Expand Up @@ -210,7 +208,7 @@ etherscan contract verify --help
| `etherscan update` | Update a Homebrew or installer-script installation |
| `etherscan whoami` | Show the active chain and masked API key |
| `etherscan config` | Get, list, or set CLI configuration |
| `etherscan chains list` | List chains built into this CLI release |
| `etherscan chains` | List chains built into this CLI release |
| `etherscan completion` | Generate shell completion |
| `etherscan version` | Print the CLI version |
| `etherscan --help` | Show command usage and available options |
Expand Down Expand Up @@ -366,7 +364,7 @@ Manage non-secret defaults with:
```sh
etherscan config list
etherscan config set default_chain=base
etherscan config set default_output=json
etherscan config set default_output=table
```

For the active chain, `--chain` takes precedence over `ETHERSCAN_CHAIN`, which takes precedence over `default_chain` in the configuration file.
Expand Down
62 changes: 39 additions & 23 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,7 @@ type globalState struct {
chain string
baseURL string
out string
json bool
compact bool
csv bool
timeout time.Duration
rate float64
verbose bool
Expand All @@ -49,6 +47,11 @@ type globalState struct {
all bool
}

// removedOutputFlags maps flags dropped in favour of -o/--output to their replacement. Without
// this, a script still passing --json gets cobra's bare "unknown flag" and has to go read --help
// to find out what replaced it.
var removedOutputFlags = map[string]string{"--json": "-o json", "--csv": "-o csv"}

type updateManager interface {
Check(context.Context, string, bool) (updater.Result, error)
Skip(string) error
Expand Down Expand Up @@ -80,10 +83,8 @@ func newRootCommand(info BuildInfo, updates updateManager) *cobra.Command {
root.PersistentFlags().StringVar(&state.apiKey, "apikey", "", "alias for --api-key")
root.PersistentFlags().StringVar(&state.chain, "chain", "", "chain name or chainid")
root.PersistentFlags().StringVar(&state.baseURL, "base-url", "", "API base URL")
root.PersistentFlags().StringVarP(&state.out, "output", "o", "", "output format: table, json, csv")
root.PersistentFlags().BoolVar(&state.json, "json", false, "print raw result as JSON")
root.PersistentFlags().StringVarP(&state.out, "output", "o", "", "output format: json (default), table, csv")
root.PersistentFlags().BoolVar(&state.compact, "compact", false, "compact JSON output")
root.PersistentFlags().BoolVar(&state.csv, "csv", false, "print result as CSV")
root.PersistentFlags().DurationVar(&state.timeout, "timeout", 30*time.Second, "request timeout")
root.PersistentFlags().Float64Var(&state.rate, "rate-limit", 3, "client-side request rate limit per second (free-tier API V2 default; raise for higher tiers)")
root.PersistentFlags().BoolVarP(&state.verbose, "verbose", "v", false, "log request URL and timing to stderr")
Expand All @@ -92,8 +93,18 @@ func newRootCommand(info BuildInfo, updates updateManager) *cobra.Command {
root.PersistentFlags().BoolVar(&state.all, "all", false, "auto-paginate list commands")
root.PersistentFlags().IntVar(&state.maxPages, "max-pages", 20, "maximum pages for --all")
hideFlags(root, "apikey", "base-url", "compact", "max-pages", "rate-limit", "timeout", "verbose", "debug", "yes")
// Registered on root only: cobra's FlagErrorFunc walks up to the parent when a subcommand
// has none, so this covers every command in the tree.
root.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
for flag, replacement := range removedOutputFlags {
if err.Error() == "unknown flag: "+flag {
return fmt.Errorf("%s was removed; use %s instead", flag, replacement)
}
}
return err
})

root.AddCommand(loginCommand(state), logoutCommand(state), uninstallCommand(state), configCommand(state), chainsCommand(), whoamiCommand(state), versionCommand(info), updateCommand(info, updates), tuiCommand(state, info, updates), completionCommand(root))
root.AddCommand(loginCommand(state), logoutCommand(state), uninstallCommand(state), configCommand(state), chainsCommand(state), whoamiCommand(state), versionCommand(info), updateCommand(info, updates), tuiCommand(state, info, updates), completionCommand(root))
addEndpointCommands(root, state)
return root
}
Expand Down Expand Up @@ -241,7 +252,10 @@ func buildRuntime(state *globalState, cfg config.File, key string) (resolvedRunt
if !strings.HasPrefix(baseURL, "https://") {
fmt.Fprintf(os.Stderr, "warning: non-HTTPS base URL: %s\n", baseURL)
}
format := output.Format(firstNonEmpty(outputFlag(state), cfg.DefaultOutput, "table"))
format, err := output.ParseFormat(firstNonEmpty(state.out, cfg.DefaultOutput, string(output.DefaultFormat)))
if err != nil {
return resolvedRuntime{}, err
}
return resolvedRuntime{
client: client.New(client.Options{BaseURL: baseURL, APIKey: key, ChainID: chain.ID, Timeout: state.timeout, RateLimit: state.rate, Verbose: state.verbose, Debug: state.debug, Stderr: os.Stderr}),
format: format,
Expand Down Expand Up @@ -470,7 +484,8 @@ func configCommand(state *globalState) *cobra.Command {
fmt.Fprintln(os.Stdout, "config:", path)
key, src := config.GetAPIKey(cfg)
fmt.Fprintf(os.Stdout, "default_chain=%s\n", cfg.DefaultChain)
fmt.Fprintf(os.Stdout, "default_output=%s\n", cfg.DefaultOutput)
// Print the effective format: DefaultOutput is empty unless explicitly set.
fmt.Fprintf(os.Stdout, "default_output=%s\n", firstNonEmpty(cfg.DefaultOutput, string(output.DefaultFormat)))
fmt.Fprintf(os.Stdout, "base_url=%s\n", cfg.BaseURL)
fmt.Fprintf(os.Stdout, "api_key=%s (%s)\n", config.Redact(key), src)
return nil
Expand Down Expand Up @@ -501,7 +516,7 @@ func configCommand(state *globalState) *cobra.Command {
case "default_chain":
fmt.Fprintln(os.Stdout, cfg.DefaultChain)
case "default_output":
fmt.Fprintln(os.Stdout, cfg.DefaultOutput)
fmt.Fprintln(os.Stdout, firstNonEmpty(cfg.DefaultOutput, string(output.DefaultFormat)))
case "base_url":
fmt.Fprintln(os.Stdout, cfg.BaseURL)
default:
Expand All @@ -512,9 +527,12 @@ func configCommand(state *globalState) *cobra.Command {
return cmd
}

func chainsCommand() *cobra.Command {
cmd := &cobra.Command{Use: "chains", Short: "Manage supported chains"}
cmd.AddCommand(&cobra.Command{Use: "list", Short: "List supported chains", Run: func(cmd *cobra.Command, args []string) {
func chainsCommand(state *globalState) *cobra.Command {
return &cobra.Command{Use: "chains", Short: "List supported chains", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
format, err := chainsFormat(state)
if err != nil {
return err
}
rows := make([]map[string]string, 0, len(chains.All()))
for _, c := range chains.All() {
freeTier := "available"
Expand All @@ -531,9 +549,8 @@ func chainsCommand() *cobra.Command {
"explorer": c.Explorer,
})
}
_ = output.WriteRows(os.Stdout, rows, output.Table, []string{"id", "name", "slug", "free_tier", "testnet", "symbol", "explorer"})
}})
return cmd
return output.WriteRows(os.Stdout, rows, format, []string{"id", "name", "slug", "free_tier", "testnet", "symbol", "explorer"})
}}
}

func whoamiCommand(state *globalState) *cobra.Command {
Expand Down Expand Up @@ -1253,14 +1270,13 @@ func confirm(ctx context.Context, prompt string) error {
}
}

func outputFlag(state *globalState) string {
if state.json {
return string(output.JSON)
}
if state.csv {
return string(output.CSV)
}
return state.out
// chainsFormat resolves the output format for `chains` without going through
// runtime(): the chain list comes from the built-in registry, so the command must
// work before `etherscan login`. A config load failure degrades to the flag value
// or the built-in default rather than failing the listing.
func chainsFormat(state *globalState) (output.Format, error) {
cfg, _, _ := config.Load()
return output.ParseFormat(firstNonEmpty(state.out, cfg.DefaultOutput, string(output.DefaultFormat)))
}

func firstNonEmpty(values ...string) string {
Expand Down
9 changes: 5 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ func Load() (File, string, error) {
if err != nil {
return File{}, "", err
}
cfg := File{DefaultChain: "ethereum", DefaultOutput: "table"}
// DefaultOutput is deliberately left empty rather than seeded: Save writes the whole
// struct, so seeding it would persist an implicit choice into every user's config.toml
// (as it once did with "table") and pin them to it across future default changes.
// Callers resolve an empty value against output.DefaultFormat instead.
cfg := File{DefaultChain: "ethereum"}
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
return cfg, path, nil
}
Expand All @@ -42,9 +46,6 @@ func Load() (File, string, error) {
if cfg.DefaultChain == "" {
cfg.DefaultChain = "ethereum"
}
if cfg.DefaultOutput == "" {
cfg.DefaultOutput = "table"
}
return cfg, path, nil
}

Expand Down
25 changes: 25 additions & 0 deletions internal/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ const (
CSV Format = "csv"
)

// DefaultFormat matches the Etherscan API, which serves application/json for every
// endpoint. Tables remain available via -o table.
const DefaultFormat = JSON

// ParseFormat validates a format name from --output or default_output. Unknown values are
// rejected rather than falling through to the table renderer, which is what Write would
// otherwise do: it tests for JSON and CSV, so anything else silently rendered as a table.
func ParseFormat(value string) (Format, error) {
switch f := Format(strings.ToLower(strings.TrimSpace(value))); f {
case Table, JSON, CSV:
return f, nil
default:
return "", fmt.Errorf("unknown output format %q (use json, table, or csv)", value)
}
}

func Write(w io.Writer, raw json.RawMessage, format Format, compact bool, columns []string) error {
if len(raw) == 0 || string(raw) == "null" {
return nil
Expand Down Expand Up @@ -216,6 +232,15 @@ func formatTableCell(column, value string) string {
if trimmed == "" {
return value
}
// Cell values may contain newlines (contract SourceCode is the worst case). writeTable
// runs with SetAutoWrapText(false), so an unsanitised newline splits one logical row
// across several physical lines and misaligns every following column. Collapse runs of
// whitespace before the truncation rules below, which would otherwise keep newlines
// that happen to fall inside the retained prefix.
if strings.ContainsAny(trimmed, "\r\n\t\v\f") {
trimmed = strings.Join(strings.Fields(trimmed), " ")
value = trimmed
}
switch strings.ToLower(column) {
case "timestamp", "timestampunix", "time_stamp":
if unix, err := strconv.ParseInt(trimmed, 10, 64); err == nil && unix > 946684800 && unix < 4102444800 {
Expand Down