diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..156a767 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +# MIT License +# +# Copyright (c) 2026 Dune Analytics +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - run: go test -race ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..39c860f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +# MIT License +# +# Copyright (c) 2026 Dune Analytics +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20b92a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out + +# Local tools +/bin/ + +# CLI binary +sparkscan-cli + +# IDE +.idea/ +.vscode/ +*.swp + +# Environment +.env + +# Conductor +.context/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..3fe4f56 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,17 @@ +--- +linters: + enable: + - goimports + - stylecheck + - lll + disable: + - errcheck + +run: + go: '1.25' + +issues: + exclude-rules: + - linters: + - lll + source: "// nolint:lll" diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..0962d26 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,56 @@ +version: 2 + +project_name: sparkscan-cli + +before: + hooks: + - go mod tidy + +builds: + - main: ./cmd + binary: sparkscan + env: + - CGO_ENABLED=0 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + +archives: + - formats: + - tar.gz + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + format_overrides: + - goos: windows + formats: + - zip + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" + - "^chore:" + +release: + github: + owner: refrakts + name: sparkscan-cli + draft: false + prerelease: auto + extra_files: + - glob: install.sh diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b00438a --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +# MIT License +# +# Copyright (c) 2026 Dune Analytics +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +.PHONY: all build test lint clean + +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//') +COMMIT ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo "unknown") +DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') +LDFLAGS = -s -w \ + -X main.version=$(VERSION) \ + -X main.commit=$(COMMIT) \ + -X main.date=$(DATE) + +all: lint test build + +build: + go build -ldflags '$(LDFLAGS)' -o bin/sparkscan ./cmd + +test: + go test -timeout=30s -race -cover ./... + +lint: bin/golangci-lint + go fmt ./... + go vet ./... + bin/golangci-lint -c .golangci.yml run ./... + go mod tidy + +run: + go run -ldflags '$(LDFLAGS)' ./cmd $(ARGS) + +bin: + mkdir -p bin + +bin/golangci-lint: bin + GOBIN=$(PWD)/bin go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 + +clean: + rm -rf bin/ diff --git a/cli/root.go b/cli/root.go new file mode 100644 index 0000000..8f635da --- /dev/null +++ b/cli/root.go @@ -0,0 +1,75 @@ +package cli + +import ( + "fmt" + "os" + + sparkscan "github.com/refrakts/sparkscan-api-go" + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmd/address" + "github.com/refrakts/sparkscan-cli/cmd/token" + "github.com/refrakts/sparkscan-cli/cmd/tx" + "github.com/refrakts/sparkscan-cli/cmd/version" + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/output" +) + +// NewRootCmd creates the root sparkscan command. +func NewRootCmd(versionStr string) *cobra.Command { + root := &cobra.Command{ + Use: "sparkscan", + Short: "CLI for the Sparkscan API", + Long: "A command-line interface for the Sparkscan blockchain explorer API.\n\nQuery addresses, tokens, and transactions on the Spark network.", + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + if cmd.Annotations != nil && cmd.Annotations["skipClient"] == "true" { + return nil + } + + apiKey, _ := cmd.Flags().GetString("api-key") + if apiKey == "" { + apiKey = os.Getenv("SPARKSCAN_API_KEY") + } + + baseURL, _ := cmd.Flags().GetString("base-url") + + var opts []sparkscan.Option + if apiKey != "" { + opts = append(opts, sparkscan.WithAPIKey(apiKey)) + } + + client, err := sparkscan.NewClient(baseURL, opts...) + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + cmdutil.SetClient(cmd, client) + return nil + }, + } + + root.PersistentFlags().String("api-key", "", "Sparkscan API key (overrides SPARKSCAN_API_KEY)") + root.PersistentFlags().String("base-url", "https://api.sparkscan.io", "API base URL") + root.PersistentFlags().String("network", "MAINNET", "Network: MAINNET or REGTEST") + output.AddFormatFlag(root) + + root.AddCommand(address.NewCmd()) + root.AddCommand(token.NewCmd()) + root.AddCommand(tx.NewCmd()) + root.AddCommand(version.NewCmd(versionStr)) + + return root +} + +// Execute runs the CLI. +func Execute(version, commit, date string) { + versionStr := fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, date) + root := NewRootCmd(versionStr) + + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/address/address.go b/cmd/address/address.go new file mode 100644 index 0000000..b690d5a --- /dev/null +++ b/cmd/address/address.go @@ -0,0 +1,16 @@ +package address + +import "github.com/spf13/cobra" + +// NewCmd returns the address parent command. +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "address", + Short: "Query Spark addresses", + } + cmd.AddCommand(newGetCmd()) + cmd.AddCommand(newTokensCmd()) + cmd.AddCommand(newTransactionsCmd()) + cmd.AddCommand(newHistoryCmd()) + return cmd +} diff --git a/cmd/address/get.go b/cmd/address/get.go new file mode 100644 index 0000000..23c0404 --- /dev/null +++ b/cmd/address/get.go @@ -0,0 +1,50 @@ +package address + +import ( + "fmt" + "io" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/output" +) + +func newGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get
", + Short: "Get address summary including balances and transaction count", + Args: cobra.ExactArgs(1), + RunE: runGet, + } +} + +func runGet(cmd *cobra.Command, args []string) error { + client := cmdutil.ClientFromCmd(cmd) + network := cmdutil.NetworkFromCmd(cmd) + + params := ogen.GetV1AddressByAddressParams{Address: args[0]} + if network != "" { + params.Network.SetTo(ogen.GetV1AddressByAddressNetwork(network)) + } + + resp, err := client.GetV1AddressByAddress(cmd.Context(), params) + if err != nil { + return err + } + + return output.PrintResult(cmd, resp, func(w io.Writer) error { + fmt.Fprintf(w, "Spark Address: %s\n", resp.SparkAddress) + fmt.Fprintf(w, "Public Key: %s\n", resp.PublicKey) + fmt.Fprintf(w, "BTC Hard Balance: %d sats\n", resp.Balance.BtcHardBalanceSats) + fmt.Fprintf(w, "BTC Soft Balance: %d sats\n", resp.Balance.BtcSoftBalanceSats) + fmt.Fprintf(w, "BTC Value (Hard): $%.2f\n", resp.Balance.BtcValueUsdHard) + fmt.Fprintf(w, "BTC Value (Soft): $%.2f\n", resp.Balance.BtcValueUsdSoft) + fmt.Fprintf(w, "Token Value (USD): $%.2f\n", resp.Balance.TotalTokenValueUsd) + fmt.Fprintf(w, "Total Value (USD): $%.2f\n", resp.TotalValueUsd) + fmt.Fprintf(w, "Token Count: %d\n", resp.TokenCount) + fmt.Fprintf(w, "Transaction Count: %d\n", resp.TransactionCount) + return nil + }) +} diff --git a/cmd/address/get_test.go b/cmd/address/get_test.go new file mode 100644 index 0000000..42c8d82 --- /dev/null +++ b/cmd/address/get_test.go @@ -0,0 +1,89 @@ +package address_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/refrakts/sparkscan-cli/internal/mock" +) + +func TestAddressGetText(t *testing.T) { + m := &mock.Client{ + GetV1AddressByAddressFn: func(_ context.Context, params ogen.GetV1AddressByAddressParams) (*ogen.GetV1AddressByAddressOK, error) { + assert.Equal(t, "sp1test", params.Address) + return &ogen.GetV1AddressByAddressOK{ + SparkAddress: "sp1test", + PublicKey: "02abc", + Balance: ogen.GetV1AddressByAddressOKBalance{ + BtcHardBalanceSats: 100000, + BtcSoftBalanceSats: 150000, + BtcValueUsdHard: 67.00, + BtcValueUsdSoft: 100.50, + TotalTokenValueUsd: 25.00, + }, + TotalValueUsd: 125.50, + TokenCount: 3, + TransactionCount: 42, + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"address", "get", "sp1test"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "Spark Address: sp1test") + assert.Contains(t, out, "Public Key: 02abc") + assert.Contains(t, out, "BTC Hard Balance: 100000 sats") + assert.Contains(t, out, "Token Count: 3") + assert.Contains(t, out, "Transaction Count: 42") +} + +func TestAddressGetJSON(t *testing.T) { + m := &mock.Client{ + GetV1AddressByAddressFn: func(_ context.Context, _ ogen.GetV1AddressByAddressParams) (*ogen.GetV1AddressByAddressOK, error) { + return &ogen.GetV1AddressByAddressOK{ + SparkAddress: "sp1abc", + TransactionCount: 7, + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"address", "get", "sp1abc", "-o", "json"}) + require.NoError(t, root.Execute()) + + var got ogen.GetV1AddressByAddressOK + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "sp1abc", got.SparkAddress) + assert.Equal(t, 7, got.TransactionCount) +} + +func TestAddressGetMissingArg(t *testing.T) { + root, _ := newTestRoot(&mock.Client{}) + root.SetArgs([]string{"address", "get"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "accepts 1 arg(s)") +} + +func TestAddressGetAPIError(t *testing.T) { + m := &mock.Client{ + GetV1AddressByAddressFn: func(_ context.Context, _ ogen.GetV1AddressByAddressParams) (*ogen.GetV1AddressByAddressOK, error) { + return nil, errors.New("api: not found") + }, + } + + root, _ := newTestRoot(m) + root.SetArgs([]string{"address", "get", "sp1bad"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "api: not found") +} diff --git a/cmd/address/history.go b/cmd/address/history.go new file mode 100644 index 0000000..8687b16 --- /dev/null +++ b/cmd/address/history.go @@ -0,0 +1,213 @@ +package address + +import ( + "fmt" + "io" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/output" +) + +func newHistoryCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "history", + Short: "Historical data for an address", + } + cmd.AddCommand(newHistoryNetWorthCmd()) + cmd.AddCommand(newHistorySatsCmd()) + cmd.AddCommand(newHistoryTokenCmd()) + return cmd +} + +func newHistoryNetWorthCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "net-worth
", + Short: "Get historical net worth snapshots for an address", + Args: cobra.ExactArgs(1), + RunE: runHistoryNetWorth, + } + cmd.Flags().Int("limit", 25, "Number of data points to return") + cmd.Flags().String("from", "", "Start timestamp (ISO 8601)") + cmd.Flags().String("to", "", "End timestamp (ISO 8601)") + cmd.Flags().String("cursor", "", "Pagination cursor") + return cmd +} + +func runHistoryNetWorth(cmd *cobra.Command, args []string) error { + client := cmdutil.ClientFromCmd(cmd) + network := cmdutil.NetworkFromCmd(cmd) + + params := ogen.GetV2HistoricalNetWorthByAddressParams{ + Address: args[0], + Network: ogen.GetV2HistoricalNetWorthByAddressNetwork(network), + } + + if limit, _ := cmd.Flags().GetInt("limit"); limit > 0 { + params.Limit.SetTo(float64(limit)) + } + if from, _ := cmd.Flags().GetString("from"); from != "" { + params.From.SetTo(from) + } + if to, _ := cmd.Flags().GetString("to"); to != "" { + params.To.SetTo(to) + } + if cursor, _ := cmd.Flags().GetString("cursor"); cursor != "" { + params.Cursor.SetTo(cursor) + } + + resp, err := client.GetV2HistoricalNetWorthByAddress(cmd.Context(), params) + if err != nil { + return err + } + + return output.PrintResult(cmd, resp, func(w io.Writer) error { + if len(resp.Data) == 0 { + fmt.Fprintln(w, "No data points found.") + return nil + } + + t := output.NewTable(w) + t.AddRow("TIMESTAMP", "NET WORTH (USD)") + for _, d := range resp.Data { + t.AddRow(fmt.Sprintf("%d", d.T), d.Nw) + } + if err := t.Flush(); err != nil { + return err + } + + if !resp.NextCursor.Null && resp.NextCursor.Value != "" { + fmt.Fprintf(w, "\nNext cursor: %s\n", resp.NextCursor.Value) + } + return nil + }) +} + +func newHistorySatsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sats
", + Short: "Get historical sats balance for an address", + Args: cobra.ExactArgs(1), + RunE: runHistorySats, + } + cmd.Flags().Int("limit", 25, "Number of data points to return") + cmd.Flags().String("from", "", "Start timestamp (ISO 8601)") + cmd.Flags().String("to", "", "End timestamp (ISO 8601)") + cmd.Flags().String("cursor", "", "Pagination cursor") + return cmd +} + +func runHistorySats(cmd *cobra.Command, args []string) error { + client := cmdutil.ClientFromCmd(cmd) + network := cmdutil.NetworkFromCmd(cmd) + + params := ogen.GetV2HistoricalSatsBalancesByAddressParams{ + Address: args[0], + Network: ogen.GetV2HistoricalSatsBalancesByAddressNetwork(network), + } + + if limit, _ := cmd.Flags().GetInt("limit"); limit > 0 { + params.Limit.SetTo(float64(limit)) + } + if from, _ := cmd.Flags().GetString("from"); from != "" { + params.From.SetTo(from) + } + if to, _ := cmd.Flags().GetString("to"); to != "" { + params.To.SetTo(to) + } + if cursor, _ := cmd.Flags().GetString("cursor"); cursor != "" { + params.Cursor.SetTo(cursor) + } + + resp, err := client.GetV2HistoricalSatsBalancesByAddress(cmd.Context(), params) + if err != nil { + return err + } + + return output.PrintResult(cmd, resp, func(w io.Writer) error { + if len(resp.Data) == 0 { + fmt.Fprintln(w, "No data points found.") + return nil + } + + t := output.NewTable(w) + t.AddRow("TIMESTAMP", "BALANCE (SATS)") + for _, d := range resp.Data { + t.AddRow(fmt.Sprintf("%d", d.T), d.B) + } + if err := t.Flush(); err != nil { + return err + } + + if !resp.NextCursor.Null && resp.NextCursor.Value != "" { + fmt.Fprintf(w, "\nNext cursor: %s\n", resp.NextCursor.Value) + } + return nil + }) +} + +func newHistoryTokenCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "token
", + Short: "Get historical token balance for an address", + Args: cobra.ExactArgs(2), + RunE: runHistoryToken, + } + cmd.Flags().Int("limit", 25, "Number of data points to return") + cmd.Flags().String("from", "", "Start timestamp (ISO 8601)") + cmd.Flags().String("to", "", "End timestamp (ISO 8601)") + cmd.Flags().String("cursor", "", "Pagination cursor") + return cmd +} + +func runHistoryToken(cmd *cobra.Command, args []string) error { + client := cmdutil.ClientFromCmd(cmd) + network := cmdutil.NetworkFromCmd(cmd) + + params := ogen.GetV2HistoricalTokensBalancesByAddressByTokenIdentifierParams{ + Address: args[0], + TokenIdentifier: args[1], + Network: ogen.GetV2HistoricalTokensBalancesByAddressByTokenIdentifierNetwork(network), + } + + if limit, _ := cmd.Flags().GetInt("limit"); limit > 0 { + params.Limit.SetTo(float64(limit)) + } + if from, _ := cmd.Flags().GetString("from"); from != "" { + params.From.SetTo(from) + } + if to, _ := cmd.Flags().GetString("to"); to != "" { + params.To.SetTo(to) + } + if cursor, _ := cmd.Flags().GetString("cursor"); cursor != "" { + params.Cursor.SetTo(cursor) + } + + resp, err := client.GetV2HistoricalTokensBalancesByAddressByTokenIdentifier(cmd.Context(), params) + if err != nil { + return err + } + + return output.PrintResult(cmd, resp, func(w io.Writer) error { + if len(resp.Data) == 0 { + fmt.Fprintln(w, "No data points found.") + return nil + } + + t := output.NewTable(w) + t.AddRow("TIMESTAMP", "BALANCE") + for _, d := range resp.Data { + t.AddRow(fmt.Sprintf("%d", d.T), d.B) + } + if err := t.Flush(); err != nil { + return err + } + + if !resp.NextCursor.Null && resp.NextCursor.Value != "" { + fmt.Fprintf(w, "\nNext cursor: %s\n", resp.NextCursor.Value) + } + return nil + }) +} diff --git a/cmd/address/history_test.go b/cmd/address/history_test.go new file mode 100644 index 0000000..744690a --- /dev/null +++ b/cmd/address/history_test.go @@ -0,0 +1,103 @@ +package address_test + +import ( + "context" + "testing" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/refrakts/sparkscan-cli/internal/mock" +) + +func TestHistoryNetWorthText(t *testing.T) { + m := &mock.Client{ + GetV2HistoricalNetWorthByAddressFn: func(_ context.Context, params ogen.GetV2HistoricalNetWorthByAddressParams) (*ogen.GetV2HistoricalNetWorthByAddressOK, error) { + assert.Equal(t, "sp1test", params.Address) + return &ogen.GetV2HistoricalNetWorthByAddressOK{ + Data: []ogen.GetV2HistoricalNetWorthByAddressOKDataItem{ + {T: 1700000000, Nw: "125.50"}, + {T: 1700000900, Nw: "130.00"}, + }, + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"address", "history", "net-worth", "sp1test"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "1700000000") + assert.Contains(t, out, "125.50") +} + +func TestHistoryNetWorthShowsCursor(t *testing.T) { + m := &mock.Client{ + GetV2HistoricalNetWorthByAddressFn: func(_ context.Context, _ ogen.GetV2HistoricalNetWorthByAddressParams) (*ogen.GetV2HistoricalNetWorthByAddressOK, error) { + return &ogen.GetV2HistoricalNetWorthByAddressOK{ + Data: []ogen.GetV2HistoricalNetWorthByAddressOKDataItem{{T: 1700000000, Nw: "100"}}, + NextCursor: ogen.NilString{Value: "cursor_xyz", Null: false}, + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"address", "history", "net-worth", "sp1test"}) + require.NoError(t, root.Execute()) + assert.Contains(t, buf.String(), "Next cursor: cursor_xyz") +} + +func TestHistoryNetWorthEmpty(t *testing.T) { + m := &mock.Client{ + GetV2HistoricalNetWorthByAddressFn: func(_ context.Context, _ ogen.GetV2HistoricalNetWorthByAddressParams) (*ogen.GetV2HistoricalNetWorthByAddressOK, error) { + return &ogen.GetV2HistoricalNetWorthByAddressOK{}, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"address", "history", "net-worth", "sp1test"}) + require.NoError(t, root.Execute()) + assert.Contains(t, buf.String(), "No data points found.") +} + +func TestHistorySatsText(t *testing.T) { + m := &mock.Client{ + GetV2HistoricalSatsBalancesByAddressFn: func(_ context.Context, _ ogen.GetV2HistoricalSatsBalancesByAddressParams) (*ogen.GetV2HistoricalSatsBalancesByAddressOK, error) { + return &ogen.GetV2HistoricalSatsBalancesByAddressOK{ + Data: []ogen.GetV2HistoricalSatsBalancesByAddressOKDataItem{ + {T: 1700000000, B: "50000"}, + }, + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"address", "history", "sats", "sp1test"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "50000") +} + +func TestHistoryTokenText(t *testing.T) { + m := &mock.Client{ + GetV2HistoricalTokensBalancesByAddressByTokenIdentifierFn: func(_ context.Context, params ogen.GetV2HistoricalTokensBalancesByAddressByTokenIdentifierParams) (*ogen.GetV2HistoricalTokensBalancesByAddressByTokenIdentifierOK, error) { + assert.Equal(t, "sp1test", params.Address) + assert.Equal(t, "tok123", params.TokenIdentifier) + return &ogen.GetV2HistoricalTokensBalancesByAddressByTokenIdentifierOK{ + Data: []ogen.GetV2HistoricalTokensBalancesByAddressByTokenIdentifierOKDataItem{ + {T: 1700000000, B: "100.5"}, + }, + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"address", "history", "token", "sp1test", "tok123"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "100.5") +} diff --git a/cmd/address/testutil_test.go b/cmd/address/testutil_test.go new file mode 100644 index 0000000..401a654 --- /dev/null +++ b/cmd/address/testutil_test.go @@ -0,0 +1,30 @@ +package address_test + +import ( + "bytes" + "context" + + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmd/address" + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/internal/mock" +) + +func newTestRoot(m *mock.Client) (*cobra.Command, *bytes.Buffer) { + root := &cobra.Command{ + Use: "sparkscan", + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + cmdutil.SetClient(cmd, m) + }, + } + root.PersistentFlags().String("network", "MAINNET", "") + root.PersistentFlags().StringP("output", "o", "text", "") + root.SetContext(context.Background()) + root.AddCommand(address.NewCmd()) + + var buf bytes.Buffer + root.SetOut(&buf) + + return root, &buf +} diff --git a/cmd/address/tokens.go b/cmd/address/tokens.go new file mode 100644 index 0000000..f272719 --- /dev/null +++ b/cmd/address/tokens.go @@ -0,0 +1,58 @@ +package address + +import ( + "fmt" + "io" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/output" +) + +func newTokensCmd() *cobra.Command { + return &cobra.Command{ + Use: "tokens
", + Short: "Get tokens held by an address", + Args: cobra.ExactArgs(1), + RunE: runTokens, + } +} + +func runTokens(cmd *cobra.Command, args []string) error { + client := cmdutil.ClientFromCmd(cmd) + network := cmdutil.NetworkFromCmd(cmd) + + params := ogen.GetV1AddressByAddressTokensParams{Address: args[0]} + if network != "" { + params.Network.SetTo(ogen.GetV1AddressByAddressTokensNetwork(network)) + } + + resp, err := client.GetV1AddressByAddressTokens(cmd.Context(), params) + if err != nil { + return err + } + + return output.PrintResult(cmd, resp, func(w io.Writer) error { + fmt.Fprintf(w, "Total Token Value: $%.2f\n\n", resp.TotalTokenValueUsd) + + if len(resp.Tokens) == 0 { + fmt.Fprintln(w, "No tokens held.") + return nil + } + + t := output.NewTable(w) + t.AddRow("TICKER", "NAME", "BALANCE", "VALUE (USD)", "TOKEN ADDRESS") + for _, tok := range resp.Tokens { + t.AddRow( + tok.Ticker, + tok.Name, + tok.Balance, + fmt.Sprintf("$%.2f", tok.ValueUsd), + tok.TokenAddress, + ) + } + return t.Flush() + }) +} diff --git a/cmd/address/tokens_test.go b/cmd/address/tokens_test.go new file mode 100644 index 0000000..b656f86 --- /dev/null +++ b/cmd/address/tokens_test.go @@ -0,0 +1,49 @@ +package address_test + +import ( + "context" + "testing" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/refrakts/sparkscan-cli/internal/mock" +) + +func TestAddressTokensText(t *testing.T) { + m := &mock.Client{ + GetV1AddressByAddressTokensFn: func(_ context.Context, params ogen.GetV1AddressByAddressTokensParams) (*ogen.GetV1AddressByAddressTokensOK, error) { + assert.Equal(t, "sp1test", params.Address) + return &ogen.GetV1AddressByAddressTokensOK{ + TotalTokenValueUsd: 100.50, + Tokens: []ogen.GetV1AddressByAddressTokensOKTokensItem{ + {Ticker: "USDT", Name: "Tether", Balance: "50.00", ValueUsd: 50.00, TokenAddress: "sp1tok1"}, + {Ticker: "BRC", Name: "BRC Token", Balance: "10.00", ValueUsd: 50.50, TokenAddress: "sp1tok2"}, + }, + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"address", "tokens", "sp1test"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "Total Token Value: $100.50") + assert.Contains(t, out, "USDT") + assert.Contains(t, out, "BRC") +} + +func TestAddressTokensEmpty(t *testing.T) { + m := &mock.Client{ + GetV1AddressByAddressTokensFn: func(_ context.Context, _ ogen.GetV1AddressByAddressTokensParams) (*ogen.GetV1AddressByAddressTokensOK, error) { + return &ogen.GetV1AddressByAddressTokensOK{}, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"address", "tokens", "sp1test"}) + require.NoError(t, root.Execute()) + assert.Contains(t, buf.String(), "No tokens held.") +} diff --git a/cmd/address/transactions.go b/cmd/address/transactions.go new file mode 100644 index 0000000..f7a4556 --- /dev/null +++ b/cmd/address/transactions.go @@ -0,0 +1,74 @@ +package address + +import ( + "fmt" + "io" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/output" +) + +func newTransactionsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "transactions
", + Aliases: []string{"txs"}, + Short: "Get transactions for an address", + Args: cobra.ExactArgs(1), + RunE: runTransactions, + } + cmd.Flags().Int("limit", 25, "Number of transactions to return") + cmd.Flags().Int("offset", 0, "Offset for pagination") + return cmd +} + +func runTransactions(cmd *cobra.Command, args []string) error { + client := cmdutil.ClientFromCmd(cmd) + network := cmdutil.NetworkFromCmd(cmd) + + params := ogen.GetV1AddressByAddressTransactionsParams{Address: args[0]} + if network != "" { + params.Network.SetTo(ogen.GetV1AddressByAddressTransactionsNetwork(network)) + } + + if limit, _ := cmd.Flags().GetInt("limit"); limit > 0 { + params.Limit = ogen.NewOptInt(limit) + } + if offset, _ := cmd.Flags().GetInt("offset"); offset > 0 { + params.Offset = ogen.NewOptInt(offset) + } + + resp, err := client.GetV1AddressByAddressTransactions(cmd.Context(), params) + if err != nil { + return err + } + + return output.PrintResult(cmd, resp, func(w io.Writer) error { + fmt.Fprintf(w, "Total: %d transactions\n\n", resp.Meta.TotalItems) + + if len(resp.Data) == 0 { + fmt.Fprintln(w, "No transactions found.") + return nil + } + + t := output.NewTable(w) + t.AddRow("ID", "TYPE", "DIRECTION", "SATS", "STATUS", "VALUE (USD)") + for _, tx := range resp.Data { + sats := "" + if v, ok := tx.AmountSats.Get(); ok { + sats = fmt.Sprintf("%d", v) + } + t.AddRow( + tx.ID, + string(tx.Type), + string(tx.Direction), + sats, + string(tx.Status), + fmt.Sprintf("$%.2f", tx.ValueUsd), + ) + } + return t.Flush() + }) +} diff --git a/cmd/address/transactions_test.go b/cmd/address/transactions_test.go new file mode 100644 index 0000000..4ec5a41 --- /dev/null +++ b/cmd/address/transactions_test.go @@ -0,0 +1,56 @@ +package address_test + +import ( + "context" + "testing" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/refrakts/sparkscan-cli/internal/mock" +) + +func TestAddressTransactionsText(t *testing.T) { + m := &mock.Client{ + GetV1AddressByAddressTransactionsFn: func(_ context.Context, params ogen.GetV1AddressByAddressTransactionsParams) (*ogen.GetV1AddressByAddressTransactionsOK, error) { + assert.Equal(t, "sp1test", params.Address) + return &ogen.GetV1AddressByAddressTransactionsOK{ + Meta: ogen.GetV1AddressByAddressTransactionsOKMeta{TotalItems: 1}, + Data: []ogen.GetV1AddressByAddressTransactionsOKDataItem{ + { + ID: "tx123", + Type: "TRANSFER", + Direction: "OUTGOING", + AmountSats: ogen.NewOptNilInt(5000), + Status: "COMPLETED", + ValueUsd: 3.35, + }, + }, + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"address", "transactions", "sp1test"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "Total: 1 transactions") + assert.Contains(t, out, "tx123") + assert.Contains(t, out, "TRANSFER") + assert.Contains(t, out, "OUTGOING") +} + +func TestAddressTransactionsEmpty(t *testing.T) { + m := &mock.Client{ + GetV1AddressByAddressTransactionsFn: func(_ context.Context, _ ogen.GetV1AddressByAddressTransactionsParams) (*ogen.GetV1AddressByAddressTransactionsOK, error) { + return &ogen.GetV1AddressByAddressTransactionsOK{}, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"address", "transactions", "sp1test"}) + require.NoError(t, root.Execute()) + assert.Contains(t, buf.String(), "No transactions found.") +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..9f45006 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "runtime/debug" + "strings" + + "github.com/refrakts/sparkscan-cli/cli" +) + +var ( + version = "" + commit = "" + date = "" +) + +func main() { + resolveVersion() + cli.Execute(version, commit, date) +} + +func resolveVersion() { + info, ok := debug.ReadBuildInfo() + if !ok { + setDefaults() + return + } + + if version == "" { + if v := info.Main.Version; v != "" && v != "(devel)" && !strings.Contains(v, "-") { + version = strings.TrimPrefix(v, "v") + } + } + + for _, s := range info.Settings { + switch s.Key { + case "vcs.revision": + if commit == "" && s.Value != "" { + commit = s.Value + if len(commit) > 12 { + commit = commit[:12] + } + } + case "vcs.time": + if date == "" && s.Value != "" { + date = s.Value + } + } + } + + setDefaults() +} + +func setDefaults() { + if version == "" { + version = "dev" + } + if commit == "" { + commit = "unknown" + } + if date == "" { + date = "unknown" + } +} diff --git a/cmd/token/get.go b/cmd/token/get.go new file mode 100644 index 0000000..c1b7fdb --- /dev/null +++ b/cmd/token/get.go @@ -0,0 +1,76 @@ +package token + +import ( + "fmt" + "io" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/output" +) + +func newGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get token details by identifier or search by name", + Args: cobra.ExactArgs(1), + RunE: runGet, + } +} + +func runGet(cmd *cobra.Command, args []string) error { + client := cmdutil.ClientFromCmd(cmd) + network := cmdutil.NetworkFromCmd(cmd) + + params := ogen.GetV1TokensByIdentifierParams{Identifier: args[0]} + if network != "" { + params.Network.SetTo(ogen.GetV1TokensByIdentifierNetwork(network)) + } + + resp, err := client.GetV1TokensByIdentifier(cmd.Context(), params) + if err != nil { + return err + } + + return output.PrintResult(cmd, resp, func(w io.Writer) error { + if detail, ok := resp.GetGetV1TokensByIdentifierOK0(); ok { + fmt.Fprintf(w, "Name: %s\n", detail.Metadata.Name) + fmt.Fprintf(w, "Ticker: %s\n", detail.Metadata.Ticker) + fmt.Fprintf(w, "Token Address: %s\n", detail.Metadata.TokenAddress) + fmt.Fprintf(w, "Token ID: %s\n", detail.Metadata.TokenIdentifier) + fmt.Fprintf(w, "Decimals: %d\n", detail.Metadata.Decimals) + fmt.Fprintf(w, "Price (USD): $%.6f\n", detail.Metadata.PriceUsd) + fmt.Fprintf(w, "Market Cap: $%.2f\n", detail.MarketCapUsd) + fmt.Fprintf(w, "Volume (24h): $%.2f\n", detail.Volume24hUsd) + fmt.Fprintf(w, "Total Supply: %s\n", detail.TotalSupply) + fmt.Fprintf(w, "Holders: %d\n", detail.Metadata.HolderCount) + fmt.Fprintf(w, "Issuer: %s\n", detail.Metadata.IssuerPublicKey) + return nil + } + + if results, ok := resp.GetGetV1TokensByIdentifierOK1ItemArray(); ok { + if len(results) == 0 { + fmt.Fprintln(w, "No tokens found.") + return nil + } + + t := output.NewTable(w) + t.AddRow("TICKER", "NAME", "TOKEN ADDRESS", "PRICE (USD)", "HOLDERS") + for _, r := range results { + t.AddRow( + r.Ticker, + r.Name, + r.TokenAddress, + fmt.Sprintf("$%.6f", r.PriceUsd), + fmt.Sprintf("%d", r.HolderCount), + ) + } + return t.Flush() + } + + fmt.Fprintln(w, "Unknown response type.") + return nil + }) +} diff --git a/cmd/token/get_test.go b/cmd/token/get_test.go new file mode 100644 index 0000000..79628e2 --- /dev/null +++ b/cmd/token/get_test.go @@ -0,0 +1,88 @@ +package token_test + +import ( + "context" + "errors" + "testing" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/refrakts/sparkscan-cli/internal/mock" +) + +func TestTokenGetDetailText(t *testing.T) { + m := &mock.Client{ + GetV1TokensByIdentifierFn: func(_ context.Context, params ogen.GetV1TokensByIdentifierParams) (*ogen.GetV1TokensByIdentifierOK, error) { + assert.Equal(t, "tok123", params.Identifier) + resp := ogen.NewGetV1TokensByIdentifierOK0GetV1TokensByIdentifierOK(ogen.GetV1TokensByIdentifierOK0{ + MarketCapUsd: 1000000.00, + Volume24hUsd: 50000.00, + TotalSupply: "21000000", + Metadata: ogen.GetV1TokensByIdentifierOK0Metadata{ + Name: "Test Token", + Ticker: "TST", + TokenAddress: "sp1tok", + TokenIdentifier: "tok123", + Decimals: 8, + PriceUsd: 0.047619, + HolderCount: 150, + IssuerPublicKey: "02abc", + }, + }) + return &resp, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"token", "get", "tok123"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "Name: Test Token") + assert.Contains(t, out, "Ticker: TST") + assert.Contains(t, out, "Holders: 150") + assert.Contains(t, out, "Market Cap: $1000000.00") +} + +func TestTokenGetSearchText(t *testing.T) { + m := &mock.Client{ + GetV1TokensByIdentifierFn: func(_ context.Context, _ ogen.GetV1TokensByIdentifierParams) (*ogen.GetV1TokensByIdentifierOK, error) { + resp := ogen.NewGetV1TokensByIdentifierOK1ItemArrayGetV1TokensByIdentifierOK([]ogen.GetV1TokensByIdentifierOK1Item{ + {Name: "Token A", Ticker: "TKA", TokenAddress: "sp1a", PriceUsd: 1.00, HolderCount: 10}, + {Name: "Token B", Ticker: "TKB", TokenAddress: "sp1b", PriceUsd: 2.50, HolderCount: 20}, + }) + return &resp, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"token", "get", "Token"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "TKA") + assert.Contains(t, out, "TKB") +} + +func TestTokenGetMissingArg(t *testing.T) { + root, _ := newTestRoot(&mock.Client{}) + root.SetArgs([]string{"token", "get"}) + err := root.Execute() + require.Error(t, err) +} + +func TestTokenGetAPIError(t *testing.T) { + m := &mock.Client{ + GetV1TokensByIdentifierFn: func(_ context.Context, _ ogen.GetV1TokensByIdentifierParams) (*ogen.GetV1TokensByIdentifierOK, error) { + return nil, errors.New("api: not found") + }, + } + + root, _ := newTestRoot(m) + root.SetArgs([]string{"token", "get", "bad"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "api: not found") +} diff --git a/cmd/token/holders.go b/cmd/token/holders.go new file mode 100644 index 0000000..30144b7 --- /dev/null +++ b/cmd/token/holders.go @@ -0,0 +1,67 @@ +package token + +import ( + "fmt" + "io" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/output" +) + +func newHoldersCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "holders ", + Short: "Get top holders of a token", + Args: cobra.ExactArgs(1), + RunE: runHolders, + } + cmd.Flags().Int("limit", 25, "Number of holders to return") + cmd.Flags().Int("offset", 0, "Offset for pagination") + return cmd +} + +func runHolders(cmd *cobra.Command, args []string) error { + client := cmdutil.ClientFromCmd(cmd) + network := cmdutil.NetworkFromCmd(cmd) + + params := ogen.GetV1TokensByIdentifierHoldersParams{Identifier: args[0]} + if network != "" { + params.Network.SetTo(ogen.GetV1TokensByIdentifierHoldersNetwork(network)) + } + + if limit, _ := cmd.Flags().GetInt("limit"); limit > 0 { + params.Limit = ogen.NewOptInt(limit) + } + if offset, _ := cmd.Flags().GetInt("offset"); offset > 0 { + params.Offset = ogen.NewOptInt(offset) + } + + resp, err := client.GetV1TokensByIdentifierHolders(cmd.Context(), params) + if err != nil { + return err + } + + return output.PrintResult(cmd, resp, func(w io.Writer) error { + fmt.Fprintf(w, "Total: %d holders\n\n", resp.Meta.TotalItems) + + if len(resp.Data) == 0 { + fmt.Fprintln(w, "No holders found.") + return nil + } + + t := output.NewTable(w) + t.AddRow("ADDRESS", "BALANCE", "PERCENTAGE", "VALUE (USD)") + for _, h := range resp.Data { + t.AddRow( + h.Address, + h.Balance, + fmt.Sprintf("%.2f%%", h.Percentage), + fmt.Sprintf("$%.2f", h.ValueUsd), + ) + } + return t.Flush() + }) +} diff --git a/cmd/token/holders_test.go b/cmd/token/holders_test.go new file mode 100644 index 0000000..fb3aa02 --- /dev/null +++ b/cmd/token/holders_test.go @@ -0,0 +1,36 @@ +package token_test + +import ( + "context" + "testing" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/refrakts/sparkscan-cli/internal/mock" +) + +func TestTokenHoldersText(t *testing.T) { + m := &mock.Client{ + GetV1TokensByIdentifierHoldersFn: func(_ context.Context, params ogen.GetV1TokensByIdentifierHoldersParams) (*ogen.GetV1TokensByIdentifierHoldersOK, error) { + assert.Equal(t, "tok123", params.Identifier) + return &ogen.GetV1TokensByIdentifierHoldersOK{ + Meta: ogen.GetV1TokensByIdentifierHoldersOKMeta{TotalItems: 2}, + Data: []ogen.GetV1TokensByIdentifierHoldersOKDataItem{ + {Address: "sp1holder1", Balance: "1000.00", Percentage: 50.0, ValueUsd: 500.00}, + {Address: "sp1holder2", Balance: "500.00", Percentage: 25.0, ValueUsd: 250.00}, + }, + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"token", "holders", "tok123"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "Total: 2 holders") + assert.Contains(t, out, "sp1holder1") + assert.Contains(t, out, "50.00%") +} diff --git a/cmd/token/issuer_lookup.go b/cmd/token/issuer_lookup.go new file mode 100644 index 0000000..801023b --- /dev/null +++ b/cmd/token/issuer_lookup.go @@ -0,0 +1,77 @@ +package token + +import ( + "fmt" + "io" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/output" +) + +func newIssuerLookupCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "issuer-lookup", + Short: "Look up token issuer information", + Args: cobra.NoArgs, + RunE: runIssuerLookup, + } + cmd.Flags().StringSlice("pubkeys", nil, "Public keys to look up (comma-separated)") + cmd.Flags().StringSlice("tokens", nil, "Token identifiers to look up (comma-separated)") + return cmd +} + +func runIssuerLookup(cmd *cobra.Command, _ []string) error { + client := cmdutil.ClientFromCmd(cmd) + network := cmdutil.NetworkFromCmd(cmd) + + pubkeys, _ := cmd.Flags().GetStringSlice("pubkeys") + tokens, _ := cmd.Flags().GetStringSlice("tokens") + + if len(pubkeys) == 0 && len(tokens) == 0 { + return fmt.Errorf("at least one of --pubkeys or --tokens is required") + } + + req := &ogen.PostV1TokensIssuerLookupReqApplicationJSON{ + Pubkeys: pubkeys, + Tokens: tokens, + } + + params := ogen.PostV1TokensIssuerLookupParams{} + if network != "" { + params.Network.SetTo(ogen.PostV1TokensIssuerLookupNetwork(network)) + } + + resp, err := client.PostV1TokensIssuerLookup(cmd.Context(), req, params) + if err != nil { + return err + } + + return output.PrintResult(cmd, resp, func(w io.Writer) error { + if len(resp.Results) == 0 { + fmt.Fprintln(w, "No results found.") + return nil + } + + t := output.NewTable(w) + t.AddRow("PUBKEY", "TOKEN ADDRESS", "TOKEN IDENTIFIER") + for _, r := range resp.Results { + pubkey := "" + if v, ok := r.Pubkey.Get(); ok { + pubkey = v + } + tokenAddr := "" + if v, ok := r.TokenAddress.Get(); ok { + tokenAddr = v + } + tokenID := "" + if v, ok := r.TokenIdentifier.Get(); ok { + tokenID = v + } + t.AddRow(pubkey, tokenAddr, tokenID) + } + return t.Flush() + }) +} diff --git a/cmd/token/list.go b/cmd/token/list.go new file mode 100644 index 0000000..ba50bd3 --- /dev/null +++ b/cmd/token/list.go @@ -0,0 +1,91 @@ +package token + +import ( + "fmt" + "io" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/output" +) + +func newListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List tokens with filtering and sorting", + Args: cobra.NoArgs, + RunE: runList, + } + cmd.Flags().Int("limit", 25, "Number of tokens to return") + cmd.Flags().String("cursor", "", "Pagination cursor") + cmd.Flags().String("sort", "", "Sort field") + cmd.Flags().String("sort-direction", "", "Sort direction: asc or desc") + cmd.Flags().Bool("has-icon", false, "Only tokens with icons") + cmd.Flags().String("holder", "", "Filter by holder address") + cmd.Flags().Int("min-holders", 0, "Minimum number of holders") + return cmd +} + +func runList(cmd *cobra.Command, _ []string) error { + client := cmdutil.ClientFromCmd(cmd) + network := cmdutil.NetworkFromCmd(cmd) + + params := ogen.GetV2TokensListParams{ + Network: ogen.GetV2TokensListNetwork(network), + } + + if limit, _ := cmd.Flags().GetInt("limit"); limit > 0 { + params.Limit.SetTo(float64(limit)) + } + if cursor, _ := cmd.Flags().GetString("cursor"); cursor != "" { + params.Cursor.SetTo(cursor) + } + if sort, _ := cmd.Flags().GetString("sort"); sort != "" { + params.Sort.SetTo(ogen.GetV2TokensListSort(sort)) + } + if dir, _ := cmd.Flags().GetString("sort-direction"); dir != "" { + params.SortDirection.SetTo(ogen.GetV2TokensListSortDirection(dir)) + } + if hasIcon, _ := cmd.Flags().GetBool("has-icon"); hasIcon { + params.HasIcon.SetTo(true) + } + if holder, _ := cmd.Flags().GetString("holder"); holder != "" { + params.HolderAddress.SetTo(holder) + } + if min, _ := cmd.Flags().GetInt("min-holders"); min > 0 { + params.MinHolders.SetTo(float64(min)) + } + + resp, err := client.GetV2TokensList(cmd.Context(), params) + if err != nil { + return err + } + + return output.PrintResult(cmd, resp, func(w io.Writer) error { + if len(resp.Tokens) == 0 { + fmt.Fprintln(w, "No tokens found.") + return nil + } + + t := output.NewTable(w) + t.AddRow("TICKER", "NAME", "HOLDERS", "TOKEN ADDRESS") + for _, tok := range resp.Tokens { + t.AddRow( + tok.Ticker, + tok.Name, + fmt.Sprintf("%d", tok.HolderCount), + tok.TokenAddress, + ) + } + if err := t.Flush(); err != nil { + return err + } + + if !resp.NextCursor.Null && resp.NextCursor.Value != "" { + fmt.Fprintf(w, "\nNext cursor: %s\n", resp.NextCursor.Value) + } + return nil + }) +} diff --git a/cmd/token/list_test.go b/cmd/token/list_test.go new file mode 100644 index 0000000..8288639 --- /dev/null +++ b/cmd/token/list_test.go @@ -0,0 +1,80 @@ +package token_test + +import ( + "context" + "testing" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/refrakts/sparkscan-cli/internal/mock" +) + +func TestTokenListText(t *testing.T) { + m := &mock.Client{ + GetV2TokensListFn: func(_ context.Context, _ ogen.GetV2TokensListParams) (*ogen.GetV2TokensListOK, error) { + return &ogen.GetV2TokensListOK{ + Tokens: []ogen.GetV2TokensListOKTokensItem{ + {Ticker: "TKA", Name: "Token A", HolderCount: 100, TokenAddress: "sp1a"}, + {Ticker: "TKB", Name: "Token B", HolderCount: 50, TokenAddress: "sp1b"}, + }, + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"token", "list"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "TKA") + assert.Contains(t, out, "Token A") + assert.Contains(t, out, "100") +} + +func TestTokenListShowsCursor(t *testing.T) { + cursor := ogen.NilString{Value: "abc123", Null: false} + m := &mock.Client{ + GetV2TokensListFn: func(_ context.Context, _ ogen.GetV2TokensListParams) (*ogen.GetV2TokensListOK, error) { + return &ogen.GetV2TokensListOK{ + Tokens: []ogen.GetV2TokensListOKTokensItem{{Ticker: "TK", Name: "Tok", HolderCount: 1, TokenAddress: "sp1"}}, + NextCursor: cursor, + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"token", "list"}) + require.NoError(t, root.Execute()) + assert.Contains(t, buf.String(), "Next cursor: abc123") +} + +func TestTokenListNoCursorWhenNull(t *testing.T) { + m := &mock.Client{ + GetV2TokensListFn: func(_ context.Context, _ ogen.GetV2TokensListParams) (*ogen.GetV2TokensListOK, error) { + return &ogen.GetV2TokensListOK{ + Tokens: []ogen.GetV2TokensListOKTokensItem{{Ticker: "TK", Name: "Tok", HolderCount: 1, TokenAddress: "sp1"}}, + NextCursor: ogen.NilString{Null: true}, + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"token", "list"}) + require.NoError(t, root.Execute()) + assert.NotContains(t, buf.String(), "Next cursor") +} + +func TestTokenListEmpty(t *testing.T) { + m := &mock.Client{ + GetV2TokensListFn: func(_ context.Context, _ ogen.GetV2TokensListParams) (*ogen.GetV2TokensListOK, error) { + return &ogen.GetV2TokensListOK{}, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"token", "list"}) + require.NoError(t, root.Execute()) + assert.Contains(t, buf.String(), "No tokens found.") +} diff --git a/cmd/token/metadata.go b/cmd/token/metadata.go new file mode 100644 index 0000000..abeea55 --- /dev/null +++ b/cmd/token/metadata.go @@ -0,0 +1,67 @@ +package token + +import ( + "fmt" + "io" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/output" +) + +func newMetadataCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "metadata", + Short: "Fetch metadata for multiple tokens", + Args: cobra.NoArgs, + RunE: runMetadata, + } + cmd.Flags().StringSlice("tokens", nil, "Token addresses (comma-separated, 1-100)") + _ = cmd.MarkFlagRequired("tokens") + return cmd +} + +func runMetadata(cmd *cobra.Command, _ []string) error { + client := cmdutil.ClientFromCmd(cmd) + network := cmdutil.NetworkFromCmd(cmd) + + tokens, _ := cmd.Flags().GetStringSlice("tokens") + + req := &ogen.PostV1TokensMetadataBatchReqApplicationJSON{ + TokenAddresses: tokens, + } + + params := ogen.PostV1TokensMetadataBatchParams{} + if network != "" { + params.Network.SetTo(ogen.PostV1TokensMetadataBatchNetwork(network)) + } + + resp, err := client.PostV1TokensMetadataBatch(cmd.Context(), req, params) + if err != nil { + return err + } + + return output.PrintResult(cmd, resp, func(w io.Writer) error { + fmt.Fprintf(w, "Total: %d tokens\n\n", resp.TotalCount) + + if len(resp.Metadata) == 0 { + fmt.Fprintln(w, "No metadata found.") + return nil + } + + t := output.NewTable(w) + t.AddRow("TICKER", "NAME", "DECIMALS", "HOLDERS", "TOKEN ADDRESS") + for _, m := range resp.Metadata { + t.AddRow( + m.Ticker, + m.Name, + fmt.Sprintf("%d", m.Decimals), + fmt.Sprintf("%d", m.HolderCount), + m.TokenAddress, + ) + } + return t.Flush() + }) +} diff --git a/cmd/token/testutil_test.go b/cmd/token/testutil_test.go new file mode 100644 index 0000000..37a13c8 --- /dev/null +++ b/cmd/token/testutil_test.go @@ -0,0 +1,30 @@ +package token_test + +import ( + "bytes" + "context" + + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmd/token" + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/internal/mock" +) + +func newTestRoot(m *mock.Client) (*cobra.Command, *bytes.Buffer) { + root := &cobra.Command{ + Use: "sparkscan", + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + cmdutil.SetClient(cmd, m) + }, + } + root.PersistentFlags().String("network", "MAINNET", "") + root.PersistentFlags().StringP("output", "o", "text", "") + root.SetContext(context.Background()) + root.AddCommand(token.NewCmd()) + + var buf bytes.Buffer + root.SetOut(&buf) + + return root, &buf +} diff --git a/cmd/token/token.go b/cmd/token/token.go new file mode 100644 index 0000000..64498e0 --- /dev/null +++ b/cmd/token/token.go @@ -0,0 +1,18 @@ +package token + +import "github.com/spf13/cobra" + +// NewCmd returns the token parent command. +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "token", + Short: "Query tokens on the Spark network", + } + cmd.AddCommand(newGetCmd()) + cmd.AddCommand(newHoldersCmd()) + cmd.AddCommand(newTransactionsCmd()) + cmd.AddCommand(newListCmd()) + cmd.AddCommand(newIssuerLookupCmd()) + cmd.AddCommand(newMetadataCmd()) + return cmd +} diff --git a/cmd/token/transactions.go b/cmd/token/transactions.go new file mode 100644 index 0000000..f5b1ef3 --- /dev/null +++ b/cmd/token/transactions.go @@ -0,0 +1,69 @@ +package token + +import ( + "fmt" + "io" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/output" +) + +func newTransactionsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "transactions ", + Aliases: []string{"txs"}, + Short: "Get transactions for a token", + Args: cobra.ExactArgs(1), + RunE: runTransactions, + } + cmd.Flags().Int("limit", 25, "Number of transactions to return") + cmd.Flags().Int("offset", 0, "Offset for pagination") + return cmd +} + +func runTransactions(cmd *cobra.Command, args []string) error { + client := cmdutil.ClientFromCmd(cmd) + network := cmdutil.NetworkFromCmd(cmd) + + params := ogen.GetV1TokensByIdentifierTransactionsParams{Identifier: args[0]} + if network != "" { + params.Network.SetTo(ogen.GetV1TokensByIdentifierTransactionsNetwork(network)) + } + + if limit, _ := cmd.Flags().GetInt("limit"); limit > 0 { + params.Limit = ogen.NewOptInt(limit) + } + if offset, _ := cmd.Flags().GetInt("offset"); offset > 0 { + params.Offset = ogen.NewOptInt(offset) + } + + resp, err := client.GetV1TokensByIdentifierTransactions(cmd.Context(), params) + if err != nil { + return err + } + + return output.PrintResult(cmd, resp, func(w io.Writer) error { + fmt.Fprintf(w, "Total: %d transactions\n\n", resp.Meta.TotalItems) + + if len(resp.Data) == 0 { + fmt.Fprintln(w, "No transactions found.") + return nil + } + + t := output.NewTable(w) + t.AddRow("ID", "TYPE", "AMOUNT", "STATUS", "VALUE (USD)") + for _, tx := range resp.Data { + t.AddRow( + tx.ID, + tx.Type, + tx.Amount, + string(tx.Status), + fmt.Sprintf("$%.2f", tx.ValueUsd), + ) + } + return t.Flush() + }) +} diff --git a/cmd/tx/get.go b/cmd/tx/get.go new file mode 100644 index 0000000..f3b4261 --- /dev/null +++ b/cmd/tx/get.go @@ -0,0 +1,61 @@ +package tx + +import ( + "fmt" + "io" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/output" +) + +func newGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get a transaction by ID", + Args: cobra.ExactArgs(1), + RunE: runGet, + } +} + +func runGet(cmd *cobra.Command, args []string) error { + client := cmdutil.ClientFromCmd(cmd) + network := cmdutil.NetworkFromCmd(cmd) + + params := ogen.GetV2TxByTxidParams{Txid: args[0]} + if network != "" { + params.Network.SetTo(ogen.GetV2TxByTxidNetwork(network)) + } + + resp, err := client.GetV2TxByTxid(cmd.Context(), params) + if err != nil { + return err + } + + return output.PrintResult(cmd, resp, func(w io.Writer) error { + fmt.Fprintf(w, "ID: %s\n", resp.ID) + fmt.Fprintf(w, "Type: %s\n", resp.Type) + fmt.Fprintf(w, "Status: %s\n", resp.Status) + + if v, ok := resp.AmountSats.Get(); ok { + fmt.Fprintf(w, "Amount (sats): %d\n", v) + } + if v, ok := resp.Amount.Get(); ok { + fmt.Fprintf(w, "Amount: %s\n", v) + } + + fmt.Fprintf(w, "Value (USD): $%.2f\n", resp.ValueUsd) + + if v, ok := resp.CreatedAt.Get(); ok { + fmt.Fprintf(w, "Created: %s\n", v.Format("2006-01-02 15:04:05 UTC")) + } + + if v, ok := resp.Txid.Get(); ok { + fmt.Fprintf(w, "Bitcoin Txid: %s\n", v) + } + + return nil + }) +} diff --git a/cmd/tx/get_test.go b/cmd/tx/get_test.go new file mode 100644 index 0000000..978a521 --- /dev/null +++ b/cmd/tx/get_test.go @@ -0,0 +1,79 @@ +package tx_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/refrakts/sparkscan-cli/internal/mock" +) + +func TestTxGetText(t *testing.T) { + m := &mock.Client{ + GetV2TxByTxidFn: func(_ context.Context, params ogen.GetV2TxByTxidParams) (*ogen.GetV2TxByTxidOK, error) { + assert.Equal(t, "txid123", params.Txid) + return &ogen.GetV2TxByTxidOK{ + ID: "txid123", + Type: "TRANSFER", + Status: "COMPLETED", + AmountSats: ogen.NewOptInt(5000), + ValueUsd: 3.35, + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"tx", "get", "txid123"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "ID: txid123") + assert.Contains(t, out, "Type: TRANSFER") + assert.Contains(t, out, "Status: COMPLETED") + assert.Contains(t, out, "Amount (sats): 5000") + assert.Contains(t, out, "Value (USD): $3.35") +} + +func TestTxGetJSON(t *testing.T) { + m := &mock.Client{ + GetV2TxByTxidFn: func(_ context.Context, _ ogen.GetV2TxByTxidParams) (*ogen.GetV2TxByTxidOK, error) { + return &ogen.GetV2TxByTxidOK{ + ID: "tx1", + Type: "MINT", + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"tx", "get", "tx1", "-o", "json"}) + require.NoError(t, root.Execute()) + + var got ogen.GetV2TxByTxidOK + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "tx1", got.ID) +} + +func TestTxGetMissingArg(t *testing.T) { + root, _ := newTestRoot(&mock.Client{}) + root.SetArgs([]string{"tx", "get"}) + err := root.Execute() + require.Error(t, err) +} + +func TestTxGetAPIError(t *testing.T) { + m := &mock.Client{ + GetV2TxByTxidFn: func(_ context.Context, _ ogen.GetV2TxByTxidParams) (*ogen.GetV2TxByTxidOK, error) { + return nil, errors.New("api: not found") + }, + } + + root, _ := newTestRoot(m) + root.SetArgs([]string{"tx", "get", "bad"}) + err := root.Execute() + require.Error(t, err) +} diff --git a/cmd/tx/latest.go b/cmd/tx/latest.go new file mode 100644 index 0000000..99f7ba1 --- /dev/null +++ b/cmd/tx/latest.go @@ -0,0 +1,65 @@ +package tx + +import ( + "fmt" + "io" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/output" +) + +func newLatestCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "latest", + Short: "Get the latest network transactions", + Args: cobra.NoArgs, + RunE: runLatest, + } + cmd.Flags().Int("limit", 25, "Number of transactions to return") + return cmd +} + +func runLatest(cmd *cobra.Command, _ []string) error { + client := cmdutil.ClientFromCmd(cmd) + network := cmdutil.NetworkFromCmd(cmd) + + params := ogen.GetV2TxLatestParams{} + if network != "" { + params.Network.SetTo(ogen.GetV2TxLatestNetwork(network)) + } + if limit, _ := cmd.Flags().GetInt("limit"); limit > 0 { + params.Limit = ogen.NewOptInt(limit) + } + + resp, err := client.GetV2TxLatest(cmd.Context(), params) + if err != nil { + return err + } + + return output.PrintResult(cmd, resp, func(w io.Writer) error { + if len(resp) == 0 { + fmt.Fprintln(w, "No transactions found.") + return nil + } + + t := output.NewTable(w) + t.AddRow("ID", "TYPE", "STATUS", "SATS", "VALUE (USD)") + for _, tx := range resp { + sats := "" + if v, ok := tx.AmountSats.Get(); ok { + sats = fmt.Sprintf("%d", v) + } + t.AddRow( + tx.ID, + string(tx.Type), + string(tx.Status), + sats, + fmt.Sprintf("$%.2f", tx.ValueUsd), + ) + } + return t.Flush() + }) +} diff --git a/cmd/tx/latest_test.go b/cmd/tx/latest_test.go new file mode 100644 index 0000000..60ac487 --- /dev/null +++ b/cmd/tx/latest_test.go @@ -0,0 +1,46 @@ +package tx_test + +import ( + "context" + "testing" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/refrakts/sparkscan-cli/internal/mock" +) + +func TestTxLatestText(t *testing.T) { + m := &mock.Client{ + GetV2TxLatestFn: func(_ context.Context, _ ogen.GetV2TxLatestParams) ([]ogen.GetV2TxLatestOKItem, error) { + return []ogen.GetV2TxLatestOKItem{ + {ID: "tx1", Type: "TRANSFER", Status: "COMPLETED", AmountSats: ogen.NewOptNilInt(1000), ValueUsd: 0.67}, + {ID: "tx2", Type: "MINT", Status: "PENDING", ValueUsd: 10.00}, + }, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"tx", "latest"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "tx1") + assert.Contains(t, out, "tx2") + assert.Contains(t, out, "TRANSFER") + assert.Contains(t, out, "MINT") +} + +func TestTxLatestEmpty(t *testing.T) { + m := &mock.Client{ + GetV2TxLatestFn: func(_ context.Context, _ ogen.GetV2TxLatestParams) ([]ogen.GetV2TxLatestOKItem, error) { + return nil, nil + }, + } + + root, buf := newTestRoot(m) + root.SetArgs([]string{"tx", "latest"}) + require.NoError(t, root.Execute()) + assert.Contains(t, buf.String(), "No transactions found.") +} diff --git a/cmd/tx/testutil_test.go b/cmd/tx/testutil_test.go new file mode 100644 index 0000000..d7ea15e --- /dev/null +++ b/cmd/tx/testutil_test.go @@ -0,0 +1,30 @@ +package tx_test + +import ( + "bytes" + "context" + + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/cmd/tx" + "github.com/refrakts/sparkscan-cli/cmdutil" + "github.com/refrakts/sparkscan-cli/internal/mock" +) + +func newTestRoot(m *mock.Client) (*cobra.Command, *bytes.Buffer) { + root := &cobra.Command{ + Use: "sparkscan", + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + cmdutil.SetClient(cmd, m) + }, + } + root.PersistentFlags().String("network", "MAINNET", "") + root.PersistentFlags().StringP("output", "o", "text", "") + root.SetContext(context.Background()) + root.AddCommand(tx.NewCmd()) + + var buf bytes.Buffer + root.SetOut(&buf) + + return root, &buf +} diff --git a/cmd/tx/tx.go b/cmd/tx/tx.go new file mode 100644 index 0000000..6620970 --- /dev/null +++ b/cmd/tx/tx.go @@ -0,0 +1,14 @@ +package tx + +import "github.com/spf13/cobra" + +// NewCmd returns the tx parent command. +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "tx", + Short: "Query transactions", + } + cmd.AddCommand(newGetCmd()) + cmd.AddCommand(newLatestCmd()) + return cmd +} diff --git a/cmd/version/version.go b/cmd/version/version.go new file mode 100644 index 0000000..1a9303b --- /dev/null +++ b/cmd/version/version.go @@ -0,0 +1,28 @@ +package version + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/refrakts/sparkscan-cli/output" +) + +// NewCmd returns the version command. +func NewCmd(version string) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the CLI version", + Annotations: map[string]string{ + "skipClient": "true", + }, + RunE: func(cmd *cobra.Command, _ []string) error { + v := map[string]string{"version": version} + return output.PrintResult(cmd, v, func(w io.Writer) error { + _, err := fmt.Fprintln(w, version) + return err + }) + }, + } +} diff --git a/cmd/version/version_test.go b/cmd/version/version_test.go new file mode 100644 index 0000000..42d2890 --- /dev/null +++ b/cmd/version/version_test.go @@ -0,0 +1,44 @@ +package version_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/refrakts/sparkscan-cli/cmd/version" + "github.com/refrakts/sparkscan-cli/output" +) + +func TestVersionText(t *testing.T) { + root := &cobra.Command{Use: "sparkscan"} + output.AddFormatFlag(root) + root.AddCommand(version.NewCmd("1.0.0 (commit: abc123, built: 2026-01-01)")) + + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"version"}) + + require.NoError(t, root.Execute()) + assert.Contains(t, buf.String(), "1.0.0") + assert.Contains(t, buf.String(), "abc123") +} + +func TestVersionJSON(t *testing.T) { + root := &cobra.Command{Use: "sparkscan"} + output.AddFormatFlag(root) + root.AddCommand(version.NewCmd("2.0.0 (commit: def456, built: 2026-06-01)")) + + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"version", "-o", "json"}) + + require.NoError(t, root.Execute()) + + var got map[string]string + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Contains(t, got["version"], "2.0.0") +} diff --git a/cmdutil/client.go b/cmdutil/client.go new file mode 100644 index 0000000..bf9acc6 --- /dev/null +++ b/cmdutil/client.go @@ -0,0 +1,59 @@ +package cmdutil + +import ( + "context" + + "github.com/refrakts/sparkscan-api-go/ogen" + "github.com/spf13/cobra" +) + +// SparkScanClient defines the API methods used by CLI commands. +type SparkScanClient interface { + // Address + GetV1AddressByAddress(ctx context.Context, params ogen.GetV1AddressByAddressParams) (*ogen.GetV1AddressByAddressOK, error) + GetV1AddressByAddressTokens(ctx context.Context, params ogen.GetV1AddressByAddressTokensParams) (*ogen.GetV1AddressByAddressTokensOK, error) + GetV1AddressByAddressTransactions(ctx context.Context, params ogen.GetV1AddressByAddressTransactionsParams) (*ogen.GetV1AddressByAddressTransactionsOK, error) + GetV2AddressByAddressTransactions(ctx context.Context, params ogen.GetV2AddressByAddressTransactionsParams) (*ogen.GetV2AddressByAddressTransactionsOK, error) + + // Historical + GetV2HistoricalNetWorthByAddress(ctx context.Context, params ogen.GetV2HistoricalNetWorthByAddressParams) (*ogen.GetV2HistoricalNetWorthByAddressOK, error) + GetV2HistoricalSatsBalancesByAddress(ctx context.Context, params ogen.GetV2HistoricalSatsBalancesByAddressParams) (*ogen.GetV2HistoricalSatsBalancesByAddressOK, error) + GetV2HistoricalTokensBalancesByAddressByTokenIdentifier(ctx context.Context, params ogen.GetV2HistoricalTokensBalancesByAddressByTokenIdentifierParams) (*ogen.GetV2HistoricalTokensBalancesByAddressByTokenIdentifierOK, error) + + // Token + GetV1TokensByIdentifier(ctx context.Context, params ogen.GetV1TokensByIdentifierParams) (*ogen.GetV1TokensByIdentifierOK, error) + GetV1TokensByIdentifierHolders(ctx context.Context, params ogen.GetV1TokensByIdentifierHoldersParams) (*ogen.GetV1TokensByIdentifierHoldersOK, error) + GetV1TokensByIdentifierTransactions(ctx context.Context, params ogen.GetV1TokensByIdentifierTransactionsParams) (*ogen.GetV1TokensByIdentifierTransactionsOK, error) + GetV2TokensList(ctx context.Context, params ogen.GetV2TokensListParams) (*ogen.GetV2TokensListOK, error) + PostV1TokensIssuerLookup(ctx context.Context, req ogen.PostV1TokensIssuerLookupReq, params ogen.PostV1TokensIssuerLookupParams) (*ogen.PostV1TokensIssuerLookupOK, error) + PostV1TokensMetadataBatch(ctx context.Context, req ogen.PostV1TokensMetadataBatchReq, params ogen.PostV1TokensMetadataBatchParams) (*ogen.PostV1TokensMetadataBatchOK, error) + + // Transactions + GetV1TxLatest(ctx context.Context, params ogen.GetV1TxLatestParams) ([]ogen.GetV1TxLatestOKItem, error) + GetV1TxByTxid(ctx context.Context, params ogen.GetV1TxByTxidParams) (*ogen.GetV1TxByTxidOK, error) + GetV2TxLatest(ctx context.Context, params ogen.GetV2TxLatestParams) ([]ogen.GetV2TxLatestOKItem, error) + GetV2TxByTxid(ctx context.Context, params ogen.GetV2TxByTxidParams) (*ogen.GetV2TxByTxidOK, error) + +} + +type clientKey struct{} + +// SetClient stores a SparkScanClient in the context. +func SetClient(cmd *cobra.Command, client SparkScanClient) { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + cmd.SetContext(context.WithValue(ctx, clientKey{}, client)) +} + +// ClientFromCmd extracts the SparkScanClient from the command's context. +func ClientFromCmd(cmd *cobra.Command) SparkScanClient { + return cmd.Context().Value(clientKey{}).(SparkScanClient) +} + +// NetworkFromCmd reads the --network flag and returns its value. +func NetworkFromCmd(cmd *cobra.Command) string { + n, _ := cmd.Flags().GetString("network") + return n +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5b1ba6f --- /dev/null +++ b/go.mod @@ -0,0 +1,46 @@ +module github.com/refrakts/sparkscan-cli + +go 1.25.0 + +require ( + github.com/refrakts/sparkscan-api-go v0.1.0 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-faster/jx v1.2.0 // indirect + github.com/go-faster/yaml v0.4.6 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ogen-go/ogen v1.20.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..eab6c22 --- /dev/null +++ b/go.sum @@ -0,0 +1,92 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= +github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= +github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ogen-go/ogen v1.20.2 h1:mEZGPST7ZeX84AkqRlFawDLwcwuzcLO5PtYpAXLT1YE= +github.com/ogen-go/ogen v1.20.2/go.mod h1:sJ1pJVp4S1RcSZlYIiMLo0QSMSt2pls4zfrc+hNKnzk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/refrakts/sparkscan-api-go v0.1.0 h1:mW7GR2IEvxoeiYGS9fF9BUByDKQZCzndj04Bgyk3zRQ= +github.com/refrakts/sparkscan-api-go v0.1.0/go.mod h1:PH2Q/uuVkH9WakXFOx6LHLHPbBeXXqY8uPHnD2wBBfU= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..ca59b7d --- /dev/null +++ b/install.sh @@ -0,0 +1,224 @@ +#!/bin/sh +# MIT License +# +# Copyright (c) 2026 Dune Analytics +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# Sparkscan CLI installer +# Usage: curl -sSfL https://raw.githubusercontent.com/refrakts/sparkscan-cli/main/install.sh | bash +# +# Environment variables: +# INSTALL_DIR — override installation directory (default: auto-detected) +# VERSION — specific version to install (default: latest) + +set -e + +REPO="refrakts/sparkscan-cli" +BINARY="sparkscan" +PROJECT="sparkscan-cli" + +main() { + need_cmd uname + need_cmd mktemp + need_cmd chmod + need_cmd rm + + os=$(detect_os) + arch=$(detect_arch) + version=$(resolve_version) + + if [ -z "$version" ]; then + err "could not determine latest version" + fi + + version_num="${version#v}" + + if [ -n "$INSTALL_DIR" ]; then + install_dir="$INSTALL_DIR" + else + install_dir=$(detect_install_dir) + fi + + case "$os" in + windows) ext="zip" ;; + *) ext="tar.gz" ;; + esac + + archive="${PROJECT}_${version_num}_${os}_${arch}.${ext}" + url="https://github.com/${REPO}/releases/download/${version}/${archive}" + checksum_url="https://github.com/${REPO}/releases/download/${version}/checksums.txt" + + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + + log "Downloading ${BINARY} ${version} for ${os}/${arch}..." + download "$url" "$tmp/$archive" + download "$checksum_url" "$tmp/checksums.txt" + + log "Verifying checksum..." + verify_checksum "$tmp/$archive" "$tmp/checksums.txt" "$archive" + + log "Extracting..." + case "$ext" in + tar.gz) tar -xzf "$tmp/$archive" -C "$tmp" ;; + zip) need_cmd unzip; unzip -q "$tmp/$archive" -d "$tmp" ;; + esac + + binary_name="$BINARY" + if [ "$os" = "windows" ]; then + binary_name="${BINARY}.exe" + fi + + if [ ! -f "$tmp/$binary_name" ]; then + err "binary '$binary_name' not found in archive" + fi + + chmod +x "$tmp/$binary_name" + + mkdir -p "$install_dir" 2>/dev/null || true + + if [ -w "$install_dir" ]; then + mv "$tmp/$binary_name" "$install_dir/$binary_name" + else + log "Installing to ${install_dir} (requires sudo)..." + sudo mkdir -p "$install_dir" + sudo mv "$tmp/$binary_name" "$install_dir/$binary_name" + fi + + echo "" >&2 + log "Sparkscan CLI ${version} installed successfully!" + log "Run 'sparkscan --help' to get started." +} + +detect_install_dir() { + for candidate in \ + "$HOME/.local/bin" \ + "$HOME/bin" \ + "$HOME/go/bin" \ + "$HOME/.cargo/bin"; do + case ":$PATH:" in + *":${candidate}:"*) + if [ -d "$candidate" ] && [ -w "$candidate" ]; then + echo "$candidate" + return + fi + ;; + esac + done + echo "/usr/local/bin" +} + +detect_os() { + os=$(uname -s | tr '[:upper:]' '[:lower:]') + case "$os" in + linux*) echo "linux" ;; + darwin*) echo "darwin" ;; + mingw*|msys*|cygwin*) echo "windows" ;; + *) err "unsupported OS: $os" ;; + esac +} + +detect_arch() { + arch=$(uname -m) + case "$arch" in + x86_64|amd64) echo "amd64" ;; + aarch64|arm64) echo "arm64" ;; + *) err "unsupported architecture: $arch" ;; + esac +} + +resolve_version() { + if [ -n "$VERSION" ]; then + case "$VERSION" in + v*) echo "$VERSION" ;; + *) echo "v$VERSION" ;; + esac + return + fi + + if has curl; then + curl -sSfL -H "Accept: application/json" \ + "https://api.github.com/repos/${REPO}/releases/latest" \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' + elif has wget; then + wget -qO- --header="Accept: application/json" \ + "https://api.github.com/repos/${REPO}/releases/latest" \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' + else + err "need curl or wget to determine latest version" + fi +} + +download() { + url="$1" + dest="$2" + if has curl; then + curl -sSfL -o "$dest" "$url" + elif has wget; then + wget -qO "$dest" "$url" + else + err "need curl or wget to download files" + fi +} + +verify_checksum() { + file="$1" + checksum_file="$2" + archive_name="$3" + + expected=$(awk -v name="$archive_name" '$2 == name || $2 == "*"name { print $1; exit }' "$checksum_file") + if [ -z "$expected" ]; then + err "checksum not found for $archive_name" + fi + + if has sha256sum; then + actual=$(sha256sum "$file" | awk '{print $1}') + elif has shasum; then + actual=$(shasum -a 256 "$file" | awk '{print $1}') + else + log "WARNING: could not verify checksum (no sha256sum or shasum found)" + return 0 + fi + + if [ "$expected" != "$actual" ]; then + err "checksum mismatch: expected $expected, got $actual" + fi +} + +has() { + command -v "$1" > /dev/null 2>&1 +} + +need_cmd() { + if ! has "$1"; then + err "required command not found: $1" + fi +} + +log() { + echo " $*" >&2 +} + +err() { + log "error: $*" + exit 1 +} + +main "$@" diff --git a/internal/mock/client.go b/internal/mock/client.go new file mode 100644 index 0000000..267ef15 --- /dev/null +++ b/internal/mock/client.go @@ -0,0 +1,98 @@ +package mock + +import ( + "context" + + "github.com/refrakts/sparkscan-api-go/ogen" +) + +// Client is a mock implementation of cmdutil.SparkScanClient. +// Set the function fields to control behavior in tests. +type Client struct { + GetV1AddressByAddressFn func(ctx context.Context, params ogen.GetV1AddressByAddressParams) (*ogen.GetV1AddressByAddressOK, error) + GetV1AddressByAddressTokensFn func(ctx context.Context, params ogen.GetV1AddressByAddressTokensParams) (*ogen.GetV1AddressByAddressTokensOK, error) + GetV1AddressByAddressTransactionsFn func(ctx context.Context, params ogen.GetV1AddressByAddressTransactionsParams) (*ogen.GetV1AddressByAddressTransactionsOK, error) + GetV2AddressByAddressTransactionsFn func(ctx context.Context, params ogen.GetV2AddressByAddressTransactionsParams) (*ogen.GetV2AddressByAddressTransactionsOK, error) + GetV2HistoricalNetWorthByAddressFn func(ctx context.Context, params ogen.GetV2HistoricalNetWorthByAddressParams) (*ogen.GetV2HistoricalNetWorthByAddressOK, error) + GetV2HistoricalSatsBalancesByAddressFn func(ctx context.Context, params ogen.GetV2HistoricalSatsBalancesByAddressParams) (*ogen.GetV2HistoricalSatsBalancesByAddressOK, error) + GetV2HistoricalTokensBalancesByAddressByTokenIdentifierFn func(ctx context.Context, params ogen.GetV2HistoricalTokensBalancesByAddressByTokenIdentifierParams) (*ogen.GetV2HistoricalTokensBalancesByAddressByTokenIdentifierOK, error) + GetV1TokensByIdentifierFn func(ctx context.Context, params ogen.GetV1TokensByIdentifierParams) (*ogen.GetV1TokensByIdentifierOK, error) + GetV1TokensByIdentifierHoldersFn func(ctx context.Context, params ogen.GetV1TokensByIdentifierHoldersParams) (*ogen.GetV1TokensByIdentifierHoldersOK, error) + GetV1TokensByIdentifierTransactionsFn func(ctx context.Context, params ogen.GetV1TokensByIdentifierTransactionsParams) (*ogen.GetV1TokensByIdentifierTransactionsOK, error) + GetV2TokensListFn func(ctx context.Context, params ogen.GetV2TokensListParams) (*ogen.GetV2TokensListOK, error) + PostV1TokensIssuerLookupFn func(ctx context.Context, req ogen.PostV1TokensIssuerLookupReq, params ogen.PostV1TokensIssuerLookupParams) (*ogen.PostV1TokensIssuerLookupOK, error) + PostV1TokensMetadataBatchFn func(ctx context.Context, req ogen.PostV1TokensMetadataBatchReq, params ogen.PostV1TokensMetadataBatchParams) (*ogen.PostV1TokensMetadataBatchOK, error) + GetV1TxLatestFn func(ctx context.Context, params ogen.GetV1TxLatestParams) ([]ogen.GetV1TxLatestOKItem, error) + GetV1TxByTxidFn func(ctx context.Context, params ogen.GetV1TxByTxidParams) (*ogen.GetV1TxByTxidOK, error) + GetV2TxLatestFn func(ctx context.Context, params ogen.GetV2TxLatestParams) ([]ogen.GetV2TxLatestOKItem, error) + GetV2TxByTxidFn func(ctx context.Context, params ogen.GetV2TxByTxidParams) (*ogen.GetV2TxByTxidOK, error) +} + +func (m *Client) GetV1AddressByAddress(ctx context.Context, params ogen.GetV1AddressByAddressParams) (*ogen.GetV1AddressByAddressOK, error) { + return m.GetV1AddressByAddressFn(ctx, params) +} + +func (m *Client) GetV1AddressByAddressTokens(ctx context.Context, params ogen.GetV1AddressByAddressTokensParams) (*ogen.GetV1AddressByAddressTokensOK, error) { + return m.GetV1AddressByAddressTokensFn(ctx, params) +} + +func (m *Client) GetV1AddressByAddressTransactions(ctx context.Context, params ogen.GetV1AddressByAddressTransactionsParams) (*ogen.GetV1AddressByAddressTransactionsOK, error) { + return m.GetV1AddressByAddressTransactionsFn(ctx, params) +} + +func (m *Client) GetV2AddressByAddressTransactions(ctx context.Context, params ogen.GetV2AddressByAddressTransactionsParams) (*ogen.GetV2AddressByAddressTransactionsOK, error) { + return m.GetV2AddressByAddressTransactionsFn(ctx, params) +} + +func (m *Client) GetV2HistoricalNetWorthByAddress(ctx context.Context, params ogen.GetV2HistoricalNetWorthByAddressParams) (*ogen.GetV2HistoricalNetWorthByAddressOK, error) { + return m.GetV2HistoricalNetWorthByAddressFn(ctx, params) +} + +func (m *Client) GetV2HistoricalSatsBalancesByAddress(ctx context.Context, params ogen.GetV2HistoricalSatsBalancesByAddressParams) (*ogen.GetV2HistoricalSatsBalancesByAddressOK, error) { + return m.GetV2HistoricalSatsBalancesByAddressFn(ctx, params) +} + +func (m *Client) GetV2HistoricalTokensBalancesByAddressByTokenIdentifier(ctx context.Context, params ogen.GetV2HistoricalTokensBalancesByAddressByTokenIdentifierParams) (*ogen.GetV2HistoricalTokensBalancesByAddressByTokenIdentifierOK, error) { + return m.GetV2HistoricalTokensBalancesByAddressByTokenIdentifierFn(ctx, params) +} + +func (m *Client) GetV1TokensByIdentifier(ctx context.Context, params ogen.GetV1TokensByIdentifierParams) (*ogen.GetV1TokensByIdentifierOK, error) { + return m.GetV1TokensByIdentifierFn(ctx, params) +} + +func (m *Client) GetV1TokensByIdentifierHolders(ctx context.Context, params ogen.GetV1TokensByIdentifierHoldersParams) (*ogen.GetV1TokensByIdentifierHoldersOK, error) { + return m.GetV1TokensByIdentifierHoldersFn(ctx, params) +} + +func (m *Client) GetV1TokensByIdentifierTransactions(ctx context.Context, params ogen.GetV1TokensByIdentifierTransactionsParams) (*ogen.GetV1TokensByIdentifierTransactionsOK, error) { + return m.GetV1TokensByIdentifierTransactionsFn(ctx, params) +} + +func (m *Client) GetV2TokensList(ctx context.Context, params ogen.GetV2TokensListParams) (*ogen.GetV2TokensListOK, error) { + return m.GetV2TokensListFn(ctx, params) +} + +func (m *Client) PostV1TokensIssuerLookup(ctx context.Context, req ogen.PostV1TokensIssuerLookupReq, params ogen.PostV1TokensIssuerLookupParams) (*ogen.PostV1TokensIssuerLookupOK, error) { + return m.PostV1TokensIssuerLookupFn(ctx, req, params) +} + +func (m *Client) PostV1TokensMetadataBatch(ctx context.Context, req ogen.PostV1TokensMetadataBatchReq, params ogen.PostV1TokensMetadataBatchParams) (*ogen.PostV1TokensMetadataBatchOK, error) { + return m.PostV1TokensMetadataBatchFn(ctx, req, params) +} + +func (m *Client) GetV1TxLatest(ctx context.Context, params ogen.GetV1TxLatestParams) ([]ogen.GetV1TxLatestOKItem, error) { + return m.GetV1TxLatestFn(ctx, params) +} + +func (m *Client) GetV1TxByTxid(ctx context.Context, params ogen.GetV1TxByTxidParams) (*ogen.GetV1TxByTxidOK, error) { + return m.GetV1TxByTxidFn(ctx, params) +} + +func (m *Client) GetV2TxLatest(ctx context.Context, params ogen.GetV2TxLatestParams) ([]ogen.GetV2TxLatestOKItem, error) { + return m.GetV2TxLatestFn(ctx, params) +} + +func (m *Client) GetV2TxByTxid(ctx context.Context, params ogen.GetV2TxByTxidParams) (*ogen.GetV2TxByTxidOK, error) { + return m.GetV2TxByTxidFn(ctx, params) +} + diff --git a/output/output.go b/output/output.go new file mode 100644 index 0000000..7439f5d --- /dev/null +++ b/output/output.go @@ -0,0 +1,64 @@ +package output + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +const ( + FormatText = "text" + FormatJSON = "json" +) + +// AddFormatFlag registers the -o/--output flag on cmd with a default of "text". +func AddFormatFlag(cmd *cobra.Command) { + cmd.PersistentFlags().StringP("output", "o", FormatText, `output format: "text" or "json"`) +} + +// FormatFromCmd reads the output flag value from cmd. +func FormatFromCmd(cmd *cobra.Command) string { + f, _ := cmd.Flags().GetString("output") + return f +} + +// PrintJSON encodes v as indented JSON and writes it to w. +func PrintJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// PrintResult renders data in the requested format. +// For JSON it marshals v directly; for text it calls textFn. +func PrintResult(cmd *cobra.Command, v any, textFn func(io.Writer) error) error { + w := cmd.OutOrStdout() + if FormatFromCmd(cmd) == FormatJSON { + return PrintJSON(w, v) + } + return textFn(w) +} + +// Table is a thin wrapper around tabwriter for consistent CLI tables. +type Table struct { + w *tabwriter.Writer +} + +// NewTable creates a new table writer. +func NewTable(out io.Writer) *Table { + return &Table{w: tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)} +} + +// AddRow adds a row of tab-separated columns. +func (t *Table) AddRow(cols ...string) { + fmt.Fprintln(t.w, strings.Join(cols, "\t")) +} + +// Flush writes any buffered data. +func (t *Table) Flush() error { + return t.w.Flush() +} diff --git a/output/output_test.go b/output/output_test.go new file mode 100644 index 0000000..449ab1c --- /dev/null +++ b/output/output_test.go @@ -0,0 +1,37 @@ +package output_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/refrakts/sparkscan-cli/output" +) + +func TestPrintJSON(t *testing.T) { + var buf bytes.Buffer + data := map[string]any{"name": "test", "count": 42} + require.NoError(t, output.PrintJSON(&buf, data)) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "test", got["name"]) + assert.Equal(t, float64(42), got["count"]) +} + +func TestTable(t *testing.T) { + var buf bytes.Buffer + tbl := output.NewTable(&buf) + tbl.AddRow("NAME", "AGE") + tbl.AddRow("Alice", "30") + tbl.AddRow("Bob", "25") + require.NoError(t, tbl.Flush()) + + out := buf.String() + assert.Contains(t, out, "NAME") + assert.Contains(t, out, "Alice") + assert.Contains(t, out, "Bob") +}