Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,12 @@ Grouped separately from the contract data commands above. All of these call the
| `etherscan gastracker oracle` | Get safe, proposed, and fast gas prices | [gasoracle](https://docs.etherscan.io/api-reference/endpoint/gasoracle.md) |
| `etherscan gastracker estimate` | Estimate confirmation time for a gas price | [gasestimate](https://docs.etherscan.io/api-reference/endpoint/gasestimate.md) |

### ENS

| Command | Description | API docs |
| --- | --- | --- |
| `etherscan ens forwardresolve` | Resolve an ENS name to its address (Ethereum mainnet) | [forwardresolve](https://docs.etherscan.io/api-reference/endpoint/forwardresolve.md) |

### Nametag

| Command | Description | API docs |
Expand Down
3 changes: 2 additions & 1 deletion cmd/etherscan/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"github.com/etherscan/etherscan-cli/internal/cli"
"github.com/etherscan/etherscan-cli/internal/output"
)

// These are overridden at release time via -ldflags (see .goreleaser.yaml). A
Expand All @@ -26,7 +27,7 @@ func main() {

root := cli.NewRootCommand(buildInfo())
if err := root.ExecuteContext(ctx); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
fmt.Fprintln(os.Stderr, "error:", output.Sanitize(err.Error()))
os.Exit(1)
}
}
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ var groupMetaOverride = map[string]groupMeta{
hint: "contract verification commands moved to \"etherscan contractverification\"",
},
"contractverification": {short: "Etherscan contract verification commands"},
"ens": {short: "Etherscan ENS name resolution commands"},
}

// groupShort returns the parent command's Short description for a CLI group.
Expand Down Expand Up @@ -160,6 +161,9 @@ func endpoints() []EndpointSpec {
{Module: "token", Action: "tokenholdercount", Use: "tokenholdercount <contract>", Short: "Get token holder count", Params: []ParamSpec{arg("contractaddress", "token contract", KindAddress)}},
{Module: "token", Action: "topholders", Use: "topholders <contract>", Short: "Get top holders", Params: []ParamSpec{arg("contractaddress", "token contract", KindAddress), p("offset", "limit", KindUint)}},
}
ens := []EndpointSpec{
{Module: "ens", Action: "forwardresolve", Use: "forwardresolve <name>", Short: "Resolve an ENS name to an address", Params: []ParamSpec{arg("name", "ENS name, e.g. etherscan.eth", KindString)}, MainnetOnly: true},
}
nametag := []EndpointSpec{
{Module: "nametag", Action: "getaddresstag", Use: "getaddresstag <addr1,addr2,...>", Short: "Get address name tags and metadata (Pro Plus)", Params: []ParamSpec{argAddresses("address", 100)}, Columns: []string{"address", "nametag", "labels", "reputation"}},
}
Expand All @@ -172,6 +176,7 @@ func endpoints() []EndpointSpec {
out = append(out, stats...)
out = append(out, token...)
out = append(out, gas...)
out = append(out, ens...)
out = append(out, nametag...)
out = append(out, proxy...)
out = append(out, EndpointSpec{Module: "getapilimit", RootLevel: true, Action: "getapilimit", Use: "apilimit", Short: "Show API credit usage", Columns: []string{"creditsUsed", "creditsAvailable", "creditLimit", "limitInterval", "intervalExpiryTimespan"}})
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestEndpointRegistryCoversPromptModules(t *testing.T) {
for _, spec := range endpoints() {
got[spec.Module]++
}
for _, module := range []string{"account", "contract", "transaction", "block", "proxy", "logs", "stats", "token", "gastracker", "nametag", "getapilimit"} {
for _, module := range []string{"account", "contract", "transaction", "block", "proxy", "logs", "stats", "token", "gastracker", "ens", "nametag", "getapilimit"} {
if got[module] == 0 {
t.Fatalf("missing module %s", module)
}
Expand Down
24 changes: 13 additions & 11 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ func endpointCommand(state *globalState, spec EndpointSpec) *cobra.Command {
}
}
if state.all && spec.Paginated {
return runAllPages(cmd.Context(), rt, spec, params, state.maxPages)
return runAllPages(cmd.Context(), rt, spec, params, state.maxPages, state.compact)
}
if err := validatePagination(spec, params); err != nil {
return err
Expand Down Expand Up @@ -381,13 +381,16 @@ func call(ctx context.Context, c *client.Client, spec EndpointSpec, params map[s
return c.Get(ctx, spec.Module, spec.Action, clean, !spec.NoRetry)
}

func runAllPages(ctx context.Context, rt resolvedRuntime, spec EndpointSpec, params map[string]string, maxPages int) error {
func runAllPages(ctx context.Context, rt resolvedRuntime, spec EndpointSpec, params map[string]string, maxPages int, compact bool) error {
offset := params["offset"]
if offset == "" {
offset = "100"
params["offset"] = offset
}
var combined []map[string]string
// Rows stay raw JSON so the combined output matches a single-page call. Converting
// them to display rows here would turn arrays (getLogs topics), numbers, booleans
// and nulls into strings; output.Write does that conversion for table and CSV only.
var combined []json.RawMessage
reachedEnd := false
limit := max(1, maxPages)
for page := 1; page <= limit; page++ {
Expand All @@ -396,12 +399,10 @@ func runAllPages(ctx context.Context, rt resolvedRuntime, spec EndpointSpec, par
if err != nil {
return err
}
rows, scalar, err := output.Rows(result.Raw)
if err != nil {
return err
}
if scalar != "" {
return output.Write(os.Stdout, result.Raw, rt.format, false, spec.Columns)
var rows []json.RawMessage
if err := json.Unmarshal(result.Raw, &rows); err != nil {
// Not a list (e.g. a scalar message): print it as a single call would.
return output.Write(os.Stdout, result.Raw, rt.format, compact, spec.Columns)
}
combined = append(combined, rows...)
fmt.Fprintf(os.Stderr, "fetched page %d (%d rows)\n", page, len(rows))
Expand All @@ -417,7 +418,7 @@ func runAllPages(ctx context.Context, rt resolvedRuntime, spec EndpointSpec, par
if err != nil {
return err
}
return output.Write(os.Stdout, raw, rt.format, false, spec.Columns)
return output.Write(os.Stdout, raw, rt.format, compact, spec.Columns)
}

// validateKeyLive checks a key against the API (getapilimit) before it is saved,
Expand Down Expand Up @@ -1011,7 +1012,7 @@ func tuiExec(rt *resolvedRuntime, index map[string]EndpointSpec) tui.Exec {
// cross-reference the docs; the CLI command tree is unaffected.
var tuiModuleOrder = []string{
"account", "block", "contract", "verification", "gastracker", "proxy",
"logs", "stats", "transaction", "token", "nametag", "usage",
"logs", "stats", "transaction", "token", "ens", "nametag", "usage",
}

// tuiGroupLabel maps a CLI command group to its sidebar label where the two differ.
Expand Down Expand Up @@ -1081,6 +1082,7 @@ var tuiActionOrder = map[string][]string{
},
"transaction": {"getstatus", "gettxreceiptstatus"},
"token": {"topholders", "tokenholderlist", "tokenholdercount", "tokeninfo"},
"ens": {"forwardresolve"},
"usage": {"getapilimit", "chainlist"},
}

Expand Down
153 changes: 153 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cli

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -242,3 +244,154 @@ func TestChainsDoesNotFallBackOnOutage(t *testing.T) {
t.Fatalf("chains printed a stale list instead of failing:\n%s", got)
}
}

// ens/forwardresolve: the CLI sends the name to the mainnet ens module, surfaces
// the server's resolution error verbatim, and refuses other chains before any
// request is made (the server only serves this action on chainid 1).
func TestEnsForwardResolve(t *testing.T) {
const address = "0xcefcc00a025d6bcc259d082c883144c36c17903f"
for _, tc := range []struct {
name string
chain string
reply string
wantRequests int
wantErr string
}{
{name: "resolves", chain: "1", reply: `{"status":"1","message":"OK","result":"` + address + `"}`, wantRequests: 1},
{name: "unresolved", chain: "1", reply: `{"status":"0","message":"NOTOK","result":"Error! Unable to resolve ENS name"}`, wantRequests: 1, wantErr: "Error! Unable to resolve ENS name"},
{name: "non-mainnet", chain: "8453", wantRequests: 0, wantErr: "only supported on Ethereum mainnet"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
q := r.URL.Query()
for key, want := range map[string]string{"module": "ens", "action": "forwardresolve", "name": "etherscan.eth", "chainid": "1"} {
if got := q.Get(key); got != want {
t.Errorf("%s = %q, want %q", key, got, want)
}
}
_, _ = w.Write([]byte(tc.reply))
}))
defer server.Close()

capture := filepath.Join(t.TempDir(), "stdout")
file, err := os.Create(capture)
if err != nil {
t.Fatal(err)
}
originalStdout := os.Stdout
os.Stdout = file
root := newRootCommand(BuildInfo{}, &fakeUpdateManager{})
root.SetArgs([]string{"--api-key", "TESTKEY", "--base-url", server.URL + "/v2/api", "--chain", tc.chain, "ens", "forwardresolve", "etherscan.eth"})
execErr := root.Execute()
os.Stdout = originalStdout
file.Close()

if requests != tc.wantRequests {
t.Fatalf("requests = %d, want %d", requests, tc.wantRequests)
}
if tc.wantErr != "" {
if execErr == nil || !strings.Contains(execErr.Error(), tc.wantErr) {
t.Fatalf("error = %v, want %q", execErr, tc.wantErr)
}
return
}
if execErr != nil {
t.Fatal(execErr)
}
got, err := os.ReadFile(capture)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), address) {
t.Fatalf("output missing resolved address; got:\n%s", got)
}
})
}
}

// --all must keep each row's JSON types. It used to flatten rows to strings, so
// getLogs topics came out as a JSON-encoded string instead of an array, and
// numbers, booleans and nulls were stringified too.
func TestAllPagesPreservesJSONTypes(t *testing.T) {
const row = `{"address":"0xabc","topics":["0xa","0xb"],"logIndex":7,"removed":false,"data":null}`
for _, compact := range []bool{false, true} {
t.Run(fmt.Sprintf("compact=%v", compact), func(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
result := "[" + row + "," + row + "]" // a full page (offset=2)
if r.URL.Query().Get("page") != "1" {
result = "[" + row + "]" // a short page ends pagination
}
fmt.Fprintf(w, `{"status":"1","message":"OK","result":%s}`, result)
}))
defer server.Close()

args := []string{"--api-key", "TESTKEY", "--base-url", server.URL + "/v2/api", "--rate-limit", "1000",
"logs", "get", "--address", "0x0000000000000000000000000000000000000abc", "--offset", "2", "--all"}
if compact {
args = append(args, "--compact")
}
out := captureStdout(t, func() {
root := newRootCommand(BuildInfo{}, &fakeUpdateManager{})
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
})
if requests != 2 {
t.Fatalf("requests = %d, want 2 pages", requests)
}
if got := strings.Count(strings.TrimSpace(out), "\n"); compact && got != 0 {
t.Fatalf("--compact --all printed %d newlines, want one line:\n%s", got, out)
}
var rows []map[string]any
if err := json.Unmarshal([]byte(out), &rows); err != nil {
t.Fatalf("output is not a JSON array: %v\n%s", err, out)
}
if len(rows) != 3 {
t.Fatalf("rows = %d, want 3", len(rows))
}
for i, r := range rows {
if topics, ok := r["topics"].([]any); !ok || len(topics) != 2 {
t.Fatalf("row %d topics = %#v, want a 2-element array", i, r["topics"])
}
if r["logIndex"] != float64(7) || r["removed"] != false {
t.Fatalf("row %d lost types: logIndex=%#v removed=%#v", i, r["logIndex"], r["removed"])
}
if data, ok := r["data"]; !ok || data != nil {
t.Fatalf("row %d data = %#v, want null", i, data)
}
}
})
}
}

// captureStdout runs fn with os.Stdout (and os.Stderr, for progress lines)
// redirected to a temp file and returns what was written to stdout.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
f, err := os.Create(filepath.Join(t.TempDir(), "stdout"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
defer devNull.Close()
originalStdout, originalStderr := os.Stdout, os.Stderr
os.Stdout, os.Stderr = f, devNull
defer func() { os.Stdout, os.Stderr = originalStdout, originalStderr }()
fn()
out, err := os.ReadFile(f.Name())
if err != nil {
t.Fatal(err)
}
return string(out)
}
Loading
Loading