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")
+}