├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── btc_specific.go ├── .github ├── pull_request_template.md ├── FUNDING.yml ├── tech-conventions │ ├── ai-compliance.md │ ├── README.md │ ├── pull-request-guidelines.md │ ├── release-versioning.md │ ├── commit-branch-conventions.md │ ├── dependency-management.md │ └── security-practices.md ├── ISSUE_TEMPLATE │ ├── question.yml │ ├── feature_request.yml │ ├── bug_report.yml │ └── new_function.yml ├── .env.custom ├── CODEOWNERS ├── SUPPORT.md ├── CONTRIBUTING.md ├── CODE_STANDARDS.md ├── .yamlfmt ├── CODE_OF_CONDUCT.md ├── actions │ ├── upload-statistics │ │ └── action.yml │ ├── parse-env │ │ └── action.yml │ └── extract-module-dir │ │ └── action.yml ├── workflows │ ├── codeql-analysis.yml │ ├── scorecard.yml │ └── fortress-warm-cache.yml ├── scripts │ └── parse-test-label.sh ├── SECURITY.md ├── AGENTS.md ├── dependabot.yml └── labels.yml ├── .gitattributes ├── go.mod ├── health.go ├── .gitignore ├── examples ├── api_key │ └── api_key.go ├── get_balance │ └── get_balance.go ├── get_tx_by_hash │ └── get_tx_by_hash.go ├── custom_http_client │ └── custom_http_client.go ├── get_utxos │ └── get_utxos.go ├── bulk_utxos │ └── bulk_utxos.go ├── get_utxo_details │ └── get_utxo_details.go ├── bulk_balance │ └── bulk_balance.go ├── bulk_script_utxos │ └── bulk_script_utxos.go └── bulk_utxos_by_txs │ └── bulk_utxos_by_tx.go ├── url_builder.go ├── search.go ├── .editorconfig ├── go.sum ├── .cursorrules ├── bsv_specific.go ├── mempool.go ├── exchange_rates.go ├── whatsonchain_test.go ├── LICENSE ├── .dockerignore ├── .devcontainer.json ├── codecov.yml ├── request_helpers.go ├── chain_info.go ├── .gitpod.yml ├── btc_specific_test.go ├── .goreleaser.yml ├── blocks.go ├── errors.go ├── bsv_specific_test.go ├── stats.go ├── search_test.go ├── exchange_rates_test.go ├── chain_support_test.go ├── errors_test.go ├── mempool_test.go ├── client_benchmark_test.go ├── http_client.go ├── tokens.go ├── .golangci.json ├── scripts.go └── chain_info_benchmark_test.go /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "golang.Go", 4 | "github.vscode-github-actions", 5 | "redhat.vscode-yaml" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /btc_specific.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | // BTCService is the interface for BTC-specific endpoints 4 | type BTCService interface { 5 | // BTC-specific methods would go here if needed 6 | } 7 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## What Changed 2 | - 3 | 4 | ## Why It Was Necessary 5 | - 6 | 7 | ## Testing Performed 8 | - 9 | 10 | ## Impact / Risk 11 | - 12 | 13 | ## Notifications 14 | - @username 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: mrz1836 4 | custom: https://mrz1818.com/?tab=tips&utm_source=github&utm_medium=sponsor-link&utm_campaign=go-whatsonchain&utm_term=go-whatsonchain&utm_content=go-whatsonchain 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Enforce Unix-style line endings for all files 2 | * text=auto eol=lf 3 | 4 | # Go source files 5 | *.go text 6 | *.mod text 7 | *.sum text 8 | 9 | # Treat binary files appropriately 10 | *.png binary 11 | *.jpg binary 12 | *.gif binary 13 | *.svg text 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mrz1836/go-whatsonchain 2 | 3 | go 1.24.0 4 | 5 | require github.com/stretchr/testify v1.11.1 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "args": [], 5 | "env": {}, 6 | "mode": "auto", 7 | "name": "Launch", 8 | "program": "${fileDirname}", 9 | "request": "launch", 10 | "type": "go" 11 | } 12 | ], 13 | "version": "0.2.0" 14 | } 15 | -------------------------------------------------------------------------------- /health.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // GetHealth simple endpoint to show API server is up and running 8 | // 9 | // For more information: https://docs.whatsonchain.com/#health 10 | func (c *Client) GetHealth(ctx context.Context) (string, error) { 11 | url := c.buildURL("/woc") 12 | return requestString(ctx, c, url) 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # OS files 15 | *.db 16 | *.DS_Store 17 | 18 | # Jetbrains 19 | .idea/ 20 | 21 | # Eclipse 22 | .project 23 | 24 | # Notes 25 | todo.md 26 | 27 | # Distribution 28 | dist 29 | 30 | # Docs 31 | postman 32 | 33 | # Fuzzing 34 | testdata 35 | -------------------------------------------------------------------------------- /examples/api_key/api_key.go: -------------------------------------------------------------------------------- 1 | // Package main demonstrates API key usage with the WhatsOnChain client. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "log" 7 | 8 | "github.com/mrz1836/go-whatsonchain" 9 | ) 10 | 11 | func main() { 12 | // Create a client with API key 13 | client, err := whatsonchain.NewClient( 14 | context.Background(), 15 | whatsonchain.WithNetwork(whatsonchain.NetworkMain), 16 | whatsonchain.WithAPIKey("your-secret-key"), 17 | ) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | 22 | log.Println("client loaded", client.UserAgent()) 23 | } 24 | -------------------------------------------------------------------------------- /url_builder.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import "fmt" 4 | 5 | // buildURL constructs a URL with the chain and network prefix 6 | // This centralizes URL construction to avoid repetition across all API methods 7 | func (c *Client) buildURL(path string, args ...interface{}) string { 8 | // Build the base URL with chain and network 9 | baseURL := fmt.Sprintf("%s%s/%s", apiEndpointBase, c.Chain(), c.Network()) 10 | 11 | // If args are provided, format the path with them 12 | if len(args) > 0 { 13 | path = fmt.Sprintf(path, args...) 14 | } 15 | 16 | // Combine base and path 17 | return baseURL + path 18 | } 19 | -------------------------------------------------------------------------------- /.github/tech-conventions/ai-compliance.md: -------------------------------------------------------------------------------- 1 | # AI Usage & Assistant Guidelines 2 | 3 | This project documents expectations for AI assistants using a few dedicated files: 4 | 5 | - [AGENTS.md](../AGENTS.md) — canonical rules for coding style, workflows, and pull requests used by [Codex](https://chatgpt.com/features/codex). 6 | - [CLAUDE.md](../CLAUDE.md) — quick checklist for the [Claude](https://www.anthropic.com/product) agent. 7 | - [.cursorrules](../../.cursorrules) — machine-readable subset of the policies for [Cursor](https://www.cursor.so/) and similar tools. 8 | 9 | Edit `AGENTS.md` first when adjusting these policies, and keep the other files in sync within the same pull request. 10 | -------------------------------------------------------------------------------- /examples/get_balance/get_balance.go: -------------------------------------------------------------------------------- 1 | // Package main demonstrates retrieving an address balance using the whatsonchain client. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "log" 7 | 8 | "github.com/mrz1836/go-whatsonchain" 9 | ) 10 | 11 | func main() { 12 | // Create a client 13 | client, err := whatsonchain.NewClient( 14 | context.Background(), 15 | whatsonchain.WithNetwork(whatsonchain.NetworkMain), 16 | ) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | // Get a balance for an address 22 | balance, _ := client.AddressBalance(context.Background(), "16ZqP5Tb22KJuvSAbjNkoiZs13mmRmexZA") 23 | log.Printf("confirmed balance: %d", balance.Confirmed) 24 | } 25 | -------------------------------------------------------------------------------- /examples/get_tx_by_hash/get_tx_by_hash.go: -------------------------------------------------------------------------------- 1 | // Package main demonstrates transaction lookup by hash functionality. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "log" 7 | 8 | "github.com/mrz1836/go-whatsonchain" 9 | ) 10 | 11 | func main() { 12 | // Create a client 13 | client, err := whatsonchain.NewClient( 14 | context.Background(), 15 | whatsonchain.WithNetwork(whatsonchain.NetworkMain), 16 | ) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | // Get the transaction information 22 | info, _ := client.GetTxByHash(context.Background(), "908c26f8227fa99f1b26f99a19648653a1382fb3b37b03870e9c138894d29b3b") 23 | log.Printf("block hash: %s", info.BlockHash) 24 | } 25 | -------------------------------------------------------------------------------- /examples/custom_http_client/custom_http_client.go: -------------------------------------------------------------------------------- 1 | // Package main demonstrates using a custom HTTP client with the whatsonchain library. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/mrz1836/go-whatsonchain" 10 | ) 11 | 12 | func main() { 13 | // use your own custom http client 14 | customClient := http.DefaultClient 15 | 16 | // Create a client with custom HTTP client 17 | client, err := whatsonchain.NewClient( 18 | context.Background(), 19 | whatsonchain.WithNetwork(whatsonchain.NetworkMain), 20 | whatsonchain.WithHTTPClient(customClient), 21 | ) 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | 26 | log.Println("client loaded", client.UserAgent()) 27 | } 28 | -------------------------------------------------------------------------------- /search.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | // GetExplorerLinks this endpoint identifies whether the posted query text is a block hash, txid or address and 10 | // responds with WoC links. Ideal for extending customized search in apps. 11 | // 12 | // For more information: https://docs.whatsonchain.com/#get-history 13 | func (c *Client) GetExplorerLinks(ctx context.Context, query string) (SearchResults, error) { 14 | postData := []byte(fmt.Sprintf(`{"query":"%s"}`, query)) 15 | url := c.buildURL("/search/links") 16 | result, err := requestAndUnmarshal[SearchResults](ctx, c, url, http.MethodPost, postData, ErrChainInfoNotFound) 17 | if err != nil { 18 | return SearchResults{}, err 19 | } 20 | return *result, nil 21 | } 22 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps maintain consistent coding styles 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = tab 8 | indent_size = 4 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [*.{yml,yaml}] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.{json,prettierrc}] 20 | indent_style = space 21 | indent_size = 4 22 | 23 | [*.{js,mjs,cjs,ts}] 24 | indent_style = space 25 | indent_size = 4 26 | 27 | [*.py] 28 | indent_style = space 29 | indent_size = 4 30 | 31 | [{Makefile,*.mk}] 32 | indent_style = tab 33 | 34 | [*.{xml,cff}] 35 | indent_style = space 36 | indent_size = 2 37 | 38 | [{LICENSE,Dockerfile,.gitignore,.dockerignore,.prettierignore}] 39 | insert_final_newline = true 40 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 6 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /.cursorrules: -------------------------------------------------------------------------------- 1 | # Cursor Rules derived from .github/AGENTS.md 2 | 3 | ## Read AGENTS.md First 4 | All contributors must read `.github/AGENTS.md` for complete guidelines. If any rule here conflicts with that file, **AGENTS.md** takes precedence. 5 | 6 | ## Coding Standards 7 | - Format with `magex format:fix`. 8 | - Lint with `magex lint` and vet with `magex vet`. 9 | - Run `magex test` before committing. 10 | - Follow Go naming and commenting conventions described in AGENTS.md. 11 | 12 | ## Commit Messages 13 | - Use the format `(): `. 14 | - Types include `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, `build`, `ci`. 15 | 16 | ## Pull Requests 17 | - Title format: `[Subsystem] Imperative and concise summary of change`. 18 | - Description must include the sections: 19 | 1. **What Changed** 20 | 2. **Why It Was Necessary** 21 | 3. **Testing Performed** 22 | 4. **Impact / Risk** 23 | -------------------------------------------------------------------------------- /examples/get_utxos/get_utxos.go: -------------------------------------------------------------------------------- 1 | // Package main demonstrates retrieving UTXOs for an address using the whatsonchain client. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "log" 7 | 8 | "github.com/mrz1836/go-whatsonchain" 9 | ) 10 | 11 | func main() { 12 | // Create a client 13 | client, err := whatsonchain.NewClient( 14 | context.Background(), 15 | whatsonchain.WithNetwork(whatsonchain.NetworkMain), 16 | ) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | // Get UTXOs for an address 22 | history, err := client.AddressUnspentTransactions(context.Background(), "16ZqP5Tb22KJuvSAbjNkoiZs13mmRmexZA") 23 | if err != nil { 24 | log.Printf("error getting utxos: %s", err.Error()) 25 | } else if len(history) == 0 { 26 | log.Println("no utxos found") 27 | } else { 28 | for index, utxo := range history { 29 | log.Printf("(%d) %s | Sats: %d \n", index+1, utxo.TxHash, utxo.Value) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /bsv_specific.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | // BSVService is the interface for BSV-specific endpoints 10 | type BSVService interface { 11 | GetOpReturnData(ctx context.Context, txHash string) (string, error) 12 | TokenService 13 | } 14 | 15 | // GetOpReturnData gets OP_RETURN data by transaction hash (BSV-only endpoint) 16 | // 17 | // For more information: https://docs.whatsonchain.com/#get-op_return-data-by-tx-hash 18 | func (c *Client) GetOpReturnData(ctx context.Context, txHash string) (string, error) { 19 | // Only available for BSV 20 | if c.Chain() != ChainBSV { 21 | return "", ErrBSVChainRequired 22 | } 23 | 24 | // https://api.whatsonchain.com/v1/bsv//tx//opreturn 25 | return c.request( 26 | ctx, 27 | fmt.Sprintf("%s%s/%s/tx/%s/opreturn", apiEndpointBase, c.Chain(), c.Network(), txHash), 28 | http.MethodGet, nil, 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /examples/bulk_utxos/bulk_utxos.go: -------------------------------------------------------------------------------- 1 | // Package main demonstrates bulk UTXO retrieval using the whatsonchain client. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "log" 7 | 8 | "github.com/mrz1836/go-whatsonchain" 9 | ) 10 | 11 | func main() { 12 | // Create a client 13 | client, err := whatsonchain.NewClient( 14 | context.Background(), 15 | whatsonchain.WithNetwork(whatsonchain.NetworkMain), 16 | ) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | // Get the balance for multiple addresses 22 | balances, _ := client.BulkUnspentTransactions( 23 | context.Background(), 24 | &whatsonchain.AddressList{ 25 | Addresses: []string{ 26 | "16ZBEb7pp6mx5EAGrdeKivztd5eRJFuvYP", 27 | "1KGHhLTQaPr4LErrvbAuGE62yPpDoRwrob", 28 | }, 29 | }, 30 | ) 31 | 32 | for _, record := range balances { 33 | log.Printf( 34 | "address: %s utxos: %d \n", 35 | record.Address, 36 | len(record.Utxos), 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yml: -------------------------------------------------------------------------------- 1 | name: Question 2 | description: General template for asking a question related to this project 3 | title: "[Question] " 4 | labels: ["question"] 5 | assignees: 6 | - mrz1836 7 | body: 8 | - type: textarea 9 | id: question 10 | attributes: 11 | label: What's your question? 12 | description: A clear and concise question, including references to specific code or files if applicable. 13 | placeholder: I'm wondering about the behavior of the package.XYZ function when... 14 | validations: 15 | required: true 16 | 17 | - type: textarea 18 | id: additional_context 19 | attributes: 20 | label: Additional context 21 | description: Add any other context, code samples, or screenshots that help explain your question. 22 | placeholder: e.g., stack traces, related functions, environment info, links to source lines 23 | validations: 24 | required: false 25 | -------------------------------------------------------------------------------- /examples/get_utxo_details/get_utxo_details.go: -------------------------------------------------------------------------------- 1 | // Package main demonstrates retrieving UTXO details using the whatsonchain client. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "log" 7 | 8 | "github.com/mrz1836/go-whatsonchain" 9 | ) 10 | 11 | func main() { 12 | // Create a client 13 | client, err := whatsonchain.NewClient( 14 | context.Background(), 15 | whatsonchain.WithNetwork(whatsonchain.NetworkMain), 16 | ) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | // Get UTXOs for an address 22 | history, err := client.AddressUnspentTransactionDetails( 23 | context.Background(), 24 | "16ZqP5Tb22KJuvSAbjNkoiZs13mmRmexZA", 0, 25 | ) 26 | if err != nil { 27 | log.Printf("error getting utxos: %s", err.Error()) 28 | } else if len(history) == 0 { 29 | log.Println("no utxos found") 30 | } else { 31 | for index, utxo := range history { 32 | log.Printf("(%d) %s | Sats: %d \n", index+1, utxo.TxHash, utxo.Value) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /mempool.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | // GetMempoolInfo this endpoint retrieves various info about the node's mempool for the selected network 9 | // 10 | // For more information: https://docs.whatsonchain.com/#get-mempool-info 11 | func (c *Client) GetMempoolInfo(ctx context.Context) (*MempoolInfo, error) { 12 | url := c.buildURL("/mempool/info") 13 | return requestAndUnmarshal[MempoolInfo](ctx, c, url, http.MethodGet, nil, ErrMempoolInfoNotFound) 14 | } 15 | 16 | // GetMempoolTransactions this endpoint will retrieve a list of transaction ids from the node's mempool 17 | // for the selected network 18 | // 19 | // For more information: https://docs.whatsonchain.com/#get-mempool-transactions 20 | func (c *Client) GetMempoolTransactions(ctx context.Context) ([]string, error) { 21 | url := c.buildURL("/mempool/raw") 22 | return requestAndUnmarshalSlice[string](ctx, c, url, http.MethodGet, nil, ErrMempoolInfoNotFound) 23 | } 24 | -------------------------------------------------------------------------------- /examples/bulk_balance/bulk_balance.go: -------------------------------------------------------------------------------- 1 | // Package main demonstrates bulk balance lookup functionality. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "log" 7 | 8 | "github.com/mrz1836/go-whatsonchain" 9 | ) 10 | 11 | func main() { 12 | // Create a client 13 | client, err := whatsonchain.NewClient( 14 | context.Background(), 15 | whatsonchain.WithNetwork(whatsonchain.NetworkMain), 16 | ) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | // Get the balance for multiple addresses 22 | balances, _ := client.BulkBalance( 23 | context.Background(), 24 | &whatsonchain.AddressList{ 25 | Addresses: []string{ 26 | "16ZBEb7pp6mx5EAGrdeKivztd5eRJFuvYP", 27 | "1KGHhLTQaPr4LErrvbAuGE62yPpDoRwrob", 28 | }, 29 | }, 30 | ) 31 | 32 | for _, record := range balances { 33 | log.Printf( 34 | "address: %s confirmed: %d unconfirmed: %d", 35 | record.Address, 36 | record.Balance.Confirmed, 37 | record.Balance.Unconfirmed, 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /examples/bulk_script_utxos/bulk_script_utxos.go: -------------------------------------------------------------------------------- 1 | // Package main demonstrates bulk script UTXO lookup functionality. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "log" 7 | 8 | "github.com/mrz1836/go-whatsonchain" 9 | ) 10 | 11 | func main() { 12 | // Create a client 13 | client, err := whatsonchain.NewClient( 14 | context.Background(), 15 | whatsonchain.WithNetwork(whatsonchain.NetworkMain), 16 | ) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | // Get the balance for multiple addresses 22 | balances, _ := client.BulkScriptUnspentTransactions( 23 | context.Background(), 24 | &whatsonchain.ScriptsList{ 25 | Scripts: []string{ 26 | "f814a7c3a40164aacc440871e8b7b14eb6a45f0ca7dcbeaea709edc83274c5e7", 27 | "995ea8d0f752f41cdd99bb9d54cb004709e04c7dc4088bcbbbb9ea5c390a43c3", 28 | }, 29 | }, 30 | ) 31 | 32 | for _, record := range balances { 33 | log.Printf( 34 | "script: %s utxos: %d \n", 35 | record.Script, 36 | len(record.Utxos), 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /exchange_rates.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | // GetExchangeRate this endpoint provides exchange rate for BSV/BTC 9 | // 10 | // For more information: https://docs.whatsonchain.com/#get-exchange-rate 11 | func (c *Client) GetExchangeRate(ctx context.Context) (*ExchangeRate, error) { 12 | url := c.buildURL("/exchangerate") 13 | return requestAndUnmarshal[ExchangeRate](ctx, c, url, http.MethodGet, nil, ErrExchangeRateNotFound) 14 | } 15 | 16 | // GetHistoricalExchangeRate this endpoint provides historical exchange rates for BSV/BTC 17 | // within a specified time range 18 | // 19 | // For more information: https://docs.whatsonchain.com/#get-historical-exchange-rate 20 | func (c *Client) GetHistoricalExchangeRate(ctx context.Context, from, to int64) ([]*HistoricalExchangeRate, error) { 21 | url := c.buildURL("/exchangerate/historical?from=%d&to=%d", from, to) 22 | return requestAndUnmarshalSlice[*HistoricalExchangeRate](ctx, c, url, http.MethodGet, nil, ErrExchangeRateNotFound) 23 | } 24 | -------------------------------------------------------------------------------- /.github/.env.custom: -------------------------------------------------------------------------------- 1 | # ================================================================================================ 2 | # 🏰 GoFortress Custom Configuration 3 | # ================================================================================================ 4 | # 5 | # Purpose: Custom configuration for GoFortress, a Go project fortress. 6 | # 7 | # Override Strategy: 8 | # - This file is used to override default settings in the GoFortress configuration. 9 | # 10 | # Tools: 11 | # - GoFortress 12 | # - go-coverage 13 | # - go-pre-commit 14 | # - GitHub Workflows 15 | # 16 | # Maintainer: @mrz1836 17 | # 18 | # ================================================================================================ 19 | 20 | # Custom Coverage Exclusions 21 | GO_COVERAGE_EXCLUDE_PATHS=test/,vendor/,testdata/,examples/,mocks/,docs/ 22 | 23 | # Google Analytics Configuration (for coverage pages) 24 | #GOOGLE_ANALYTICS_ID= 25 | 26 | # Coverage Report Provider (e.g., codecov, internal) 27 | GO_COVERAGE_PROVIDER=codecov 28 | 29 | # Codecov Configuration (only used when provider=codecov) 30 | CODECOV_TOKEN_REQUIRED=true 31 | -------------------------------------------------------------------------------- /whatsonchain_test.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import "context" 4 | 5 | const ( 6 | testKey = "test-key-for-woc-api" 7 | ) 8 | 9 | // newMockClient returns a client for mocking 10 | func newMockClient(httpClient HTTPInterface) ClientInterface { 11 | client, _ := NewClient( 12 | context.Background(), 13 | WithNetwork(NetworkTest), 14 | WithAPIKey(testKey), 15 | WithHTTPClient(httpClient), 16 | ) 17 | return client 18 | } 19 | 20 | // newMockClientBSV returns a BSV client for mocking 21 | func newMockClientBSV(httpClient HTTPInterface) ClientInterface { 22 | client, _ := NewClient( 23 | context.Background(), 24 | WithChain(ChainBSV), 25 | WithNetwork(NetworkTest), 26 | WithAPIKey(testKey), 27 | WithHTTPClient(httpClient), 28 | ) 29 | return client 30 | } 31 | 32 | // newMockClientBTC returns a BTC client for mocking 33 | func newMockClientBTC(httpClient HTTPInterface) ClientInterface { 34 | client, _ := NewClient( 35 | context.Background(), 36 | WithChain(ChainBTC), 37 | WithNetwork(NetworkTest), 38 | WithAPIKey(testKey), 39 | WithHTTPClient(httpClient), 40 | ) 41 | return client 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 @MrZ1836 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | ## 2 | ## Specific to .dockerignore 3 | ## 4 | 5 | .git/ 6 | Dockerfile 7 | contrib/ 8 | 9 | ## 10 | ## Common with .gitignore 11 | ## 12 | 13 | # Temporary files 14 | *~ 15 | *# 16 | .#* 17 | 18 | # Vendors 19 | node_modules/ 20 | vendor/ 21 | 22 | # Binaries for programs and plugins 23 | dist/ 24 | gin-bin 25 | *.exe 26 | *.exe~ 27 | *.dll 28 | *.so 29 | *.dylib 30 | 31 | # Byte-compiled / optimized / DLL files 32 | __pycache__/ 33 | **/__pycache__/ 34 | *.pyc 35 | *.pyo 36 | *.pyd 37 | .Python 38 | *.py[cod] 39 | *$py.class 40 | .pytest_cache/ 41 | .mypy_cache/ 42 | 43 | # Test binary, build with `go test -c` 44 | *.test 45 | 46 | # Output of the go coverage tool, specifically when used with LiteIDE 47 | *.out 48 | 49 | # Virtual environments 50 | .venv 51 | ../venv 52 | .DS_Store 53 | .AppleDouble 54 | .LSOverride 55 | ._* 56 | 57 | # Temporary directories in the project 58 | bin 59 | tmp 60 | 61 | # Project files not needed in the container 62 | .cursorrules 63 | .editorconfig 64 | .github 65 | .gitpod.yml 66 | .golangci.json 67 | .golangci.yml 68 | .goreleaser.yml 69 | .vscode 70 | docs 71 | LICENSE 72 | README.md 73 | codecov.yml 74 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # CODEOWNERS 2 | # See https://docs.github.com/articles/about-codeowners for syntax and rules. 3 | # The most specific rule that matches takes precedence. 4 | 5 | # Default owner for the entire repository 6 | * @mrz1836 7 | 8 | # GitHub Actions workflows 9 | .github/workflows/* @mrz1836 10 | .github/.env.base @mrz1836 11 | .github/.env.custom @mrz1836 12 | 13 | # MAGE-X 14 | .mage.yaml @mrz1836 15 | 16 | # Documentation files 17 | *.md @mrz1836 18 | CITATION.cff @mrz1836 19 | 20 | # Build and makefiles 21 | Makefile @mrz1836 22 | .make/*.mk @mrz1836 23 | *.mk @mrz1836 24 | 25 | # Go module dependencies 26 | go.mod @mrz1836 27 | go.sum @mrz1836 28 | 29 | # Code Coverage 30 | codecov.yml @mrz1836 31 | 32 | # Linter configuration 33 | .golangci.json @mrz1836 34 | .golangci.yml @mrz1836 35 | 36 | # AI Config Files 37 | .github/AGENTS.md @mrz1836 38 | .cursorrules @mrz1836 39 | .github/CLAUDE.md @mrz1836 40 | sweep.yml @mrz1836 41 | 42 | # Security and configuration files 43 | .github/SECURITY.md @mrz1836 44 | .github/.gitleaks.toml @mrz1836 45 | 46 | # Repository configuration 47 | .github/labels.yml @mrz1836 48 | .github/dependabot.yml @mrz1836 49 | -------------------------------------------------------------------------------- /examples/bulk_utxos_by_txs/bulk_utxos_by_tx.go: -------------------------------------------------------------------------------- 1 | // Package main demonstrates bulk transaction details processing functionality. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "log" 7 | 8 | "github.com/mrz1836/go-whatsonchain" 9 | ) 10 | 11 | func main() { 12 | // Create a client 13 | client, err := whatsonchain.NewClient( 14 | context.Background(), 15 | whatsonchain.WithNetwork(whatsonchain.NetworkMain), 16 | ) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | // Get the balance for multiple addresses 22 | balances, _ := client.BulkTransactionDetailsProcessor( 23 | context.Background(), 24 | &whatsonchain.TxHashes{ 25 | TxIDs: []string{ 26 | "cc84bf6aa5f0c3ab7e1e7f71bc40325576d0561bd07908ff8354308fcba7b4f0", 27 | "a33d408055fd8b2ac571a7d2016cf9f572d6a8cf5d905c0858d57818025c363a", 28 | "7677542b511bdf4d445dad6e835dd921f7fbe25833613479022ff1803007562e", 29 | "766d9f2b7da5f13aa679736d7a172b1b26de984838a7e9a2302a99c1a4c908fd", 30 | "60b22f4cf81b5e1aec080529096fd3cc99dd7eae09626c088f13768934aa7a4d", 31 | "c1b38c534773fdc5d2a600858e1c02572f309b40ef6182fa9149756ac7be15b1", 32 | "10b44f6f8a739a6223f911f7b52cd40c7b6b2abecfc287c2f9f11af3f8f7ed61", 33 | }, 34 | }, 35 | ) 36 | 37 | for _, record := range balances { 38 | log.Printf( 39 | "tx: %s outputs: %d \n", 40 | record.TxID, 41 | len(record.Vout), 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "customizations": { 3 | "vscode": { 4 | "extensions": [ 5 | "golang.Go", 6 | "github.vscode-github-actions", 7 | "eamodio.gitlens" 8 | ], 9 | "settings": { 10 | "editor.codeActionsOnSave": { 11 | "source.organizeImports": "explicit" 12 | }, 13 | "editor.formatOnSave": true, 14 | "go.lintTool": "golangci-lint", 15 | "go.toolsEnvVars": { 16 | "GOFLAGS": "-buildvcs=false" 17 | }, 18 | "go.useLanguageServer": true 19 | } 20 | } 21 | }, 22 | "features": { 23 | "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, 24 | "ghcr.io/devcontainers/features/github-cli:1": {} 25 | }, 26 | "image": "mcr.microsoft.com/devcontainers/go:0-1.24-bullseye", 27 | "mounts": [ 28 | "type=cache,target=/home/vscode/.cache/go-build", 29 | "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock" 30 | ], 31 | "name": "go-whatsonchain dev container", 32 | "postCreateCommand": "magex lint && magex vet && magex test", 33 | "postStartCommand": "go install github.com/mrz1836/mage-x/cmd/magex@latest", 34 | "remoteUser": "vscode", 35 | "runArgs": [ 36 | "--cap-drop=ALL", 37 | "--security-opt", 38 | "no-new-privileges:true" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | # Reference: https://docs.codecov.com/docs/codecovyml-reference 2 | # ---------------------- 3 | codecov: 4 | require_ci_to_pass: true 5 | 6 | # Coverage configuration 7 | # ---------------------- 8 | coverage: 9 | status: 10 | patch: false 11 | range: 70..90 # The First number represents red, and the second represents green 12 | # (default is 70..100) 13 | round: down # up, down, or nearest 14 | precision: 2 # Number of decimal places, between 0 and 5 15 | 16 | # Flag Management - Simple carryforward for everything 17 | # ---------------------- 18 | flag_management: 19 | default_rules: 20 | carryforward: true # This keeps coverage from previous uploads 21 | statuses: 22 | - type: project 23 | target: auto 24 | threshold: 2% 25 | 26 | # Ignoring Paths 27 | # -------------- 28 | # which folders/files to ignore 29 | ignore: 30 | - ".github/**" 31 | - ".mage-cache/**" 32 | - ".vscode/**" 33 | - "bin/**" 34 | - "example/**" 35 | - "examples/**" 36 | - "mocks/**" 37 | - "testing/**" 38 | 39 | # Parsers 40 | # -------------- 41 | parsers: 42 | gcov: 43 | branch_detection: 44 | conditional: yes 45 | loop: yes 46 | method: no 47 | macro: no 48 | 49 | # Pull request comments: 50 | # ---------------------- 51 | # Diff is the Coverage Diff of the pull request. 52 | # Files are the files impacted by the pull request 53 | comment: 54 | layout: "reach,diff,flags,files,footer" 55 | behavior: default 56 | require_changes: false 57 | -------------------------------------------------------------------------------- /request_helpers.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | ) 7 | 8 | // requestAndUnmarshal is a generic helper that performs a request and will unmarshal the response 9 | // into a pointer to the specified type T 10 | func requestAndUnmarshal[T any](ctx context.Context, c *Client, url, method string, payload []byte, emptyErr error) (*T, error) { 11 | resp, err := c.request(ctx, url, method, payload) 12 | if err != nil { 13 | return nil, err 14 | } 15 | 16 | if len(resp) == 0 { 17 | return nil, emptyErr 18 | } 19 | 20 | var result T 21 | if err = json.Unmarshal([]byte(resp), &result); err != nil { 22 | return nil, err 23 | } 24 | 25 | return &result, nil 26 | } 27 | 28 | // requestAndUnmarshalSlice is a generic helper that performs a request and unmarshals the response 29 | // into a slice of the specified type T 30 | func requestAndUnmarshalSlice[T any](ctx context.Context, c *Client, url, method string, payload []byte, emptyErr error) ([]T, error) { 31 | resp, err := c.request(ctx, url, method, payload) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | if len(resp) == 0 { 37 | return nil, emptyErr 38 | } 39 | 40 | var result []T 41 | if err = json.Unmarshal([]byte(resp), &result); err != nil { 42 | return nil, err 43 | } 44 | 45 | return result, nil 46 | } 47 | 48 | // requestString is a helper that performs a GET request and returns the raw string response 49 | func requestString(ctx context.Context, c *Client, url string) (string, error) { 50 | return c.request(ctx, url, "GET", nil) 51 | } 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea or improvement for this project 3 | title: "[Feature] " 4 | labels: ["idea"] 5 | assignees: 6 | - mrz1836 7 | body: 8 | - type: textarea 9 | id: problem_description 10 | attributes: 11 | label: Is your feature request related to a problem? 12 | description: Describe the problem you're experiencing. What makes this feature necessary or helpful? 13 | placeholder: I'm always frustrated when I try to use X and it doesn't support Y... 14 | validations: 15 | required: true 16 | 17 | - type: textarea 18 | id: solution 19 | attributes: 20 | label: Describe the solution you'd like 21 | description: Provide a clear and concise description of what you'd like to see implemented. 22 | placeholder: Add support for this custom function to custom map... 23 | validations: 24 | required: true 25 | 26 | - type: textarea 27 | id: alternatives 28 | attributes: 29 | label: Describe alternatives you've considered 30 | description: List any alternative solutions or features you’ve thought about or tried. 31 | placeholder: I also considered doing this through a wrapper or middleware... 32 | validations: 33 | required: false 34 | 35 | - type: textarea 36 | id: context 37 | attributes: 38 | label: Additional context 39 | description: Add any other context or screenshots that help explain your feature request. 40 | placeholder: Links to relevant docs, examples, or screenshots 41 | validations: 42 | required: false 43 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # 🛟 Support Guide 2 | 3 | Need help with **go-whatsonchain**? You're in the right place. Here’s how to get support, report issues, and stay aligned with project guidelines. 4 | 5 |
6 | 7 | ## 💬 Questions & Discussion 8 | 9 | Before asking, check the existing threads: 10 | 11 | * 🔍 Search [Issues](https://github.com/mrz1836/go-whatsonchain/issues) or [Pull Requests](https://github.com/mrz1836/go-whatsonchain/pulls?q=is%3Apr+is%3Aopen+is%3Aclosed) 12 | * 🆕 Can’t find what you need? Start a [new issue](https://github.com/mrz1836/go-whatsonchain/issues/new?template=question.yml). 13 | 14 |
15 | 16 | ## 🐞 Reporting Issues 17 | 18 | Found a bug? 19 | 20 | 1. Check the [issue tracker](https://github.com/mrz1836/go-whatsonchain/issues) to avoid duplicates. 21 | 2. If it’s new, open an issue with: 22 | 23 | * Clear steps to reproduce 24 | * Expected vs. actual behavior 25 | * Relevant code or inputs 26 | 27 | More detail = faster fixes ✅ 28 | 29 |
30 | 31 | ## 🔐 Security Vulnerabilities 32 | 33 | Security first: 34 | 35 | * **Do not** report vulnerabilities in public issues. 36 | * Follow our [Security Policy](SECURITY.md) for confidential disclosure. 37 | 38 |
39 | 40 | ## 🧭 Project Standards 41 | 42 | Everything from commit rules to contributor expectations is in [AGENTS.md](./AGENTS.md). If you’re contributing or troubleshooting, **read it first**. 43 | 44 |
45 | 46 | ## 📬 Private Contact 47 | 48 | For sensitive or non-public concerns, reach out to: 49 | 📧 [go-whatsonchain@mrz1818.com](mailto:go-whatsonchain@mrz1818.com) 50 | 51 |
52 | 53 | Thanks for your interest and support - we're here to help, and we appreciate your contributions. 🚀 54 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 🤝 Contributing Guide 2 | 3 | Thanks for taking the time to contribute! This project thrives on clear, well-tested, idiomatic Go code. Here's how you can help: 4 | 5 |
6 | 7 | ## 📦 How to Contribute 8 | 9 | 1. Fork the repo. 10 | 2. Create a new branch. 11 | 3. Install the [pre-commit hooks](https://github.com/mrz1836/go-pre-commit). 12 | 4. Commit *one feature per commit*. 13 | 5. Write tests. 14 | 6. Open a pull request with a clear list of changes. 15 | 16 | More info on [pull requests](http://help.github.com/pull-requests/). 17 | 18 |
19 | 20 | ## 🧪 Testing 21 | 22 | All tests follow standard Go patterns. We love: 23 | 24 | * ✅ [Go Tests](https://golang.org/pkg/testing/) 25 | * 📘 [Go Examples](https://golang.org/pkg/testing/#hdr-Examples) 26 | * ⚡ [Go Benchmarks](https://golang.org/pkg/testing/#hdr-Benchmarks) 27 | 28 | Tests should be: 29 | 30 | * Easy to understand 31 | * Focused on one behavior 32 | * Fast 33 | 34 | This project aims for >= **90% code coverage**. Every code path must be tested to 35 | keep the Codecov badge green and CI passing. 36 | 37 |
38 | 39 | ## 🧹 Coding Conventions 40 | 41 | We follow [Effective Go](https://golang.org/doc/effective_go.html), plus: 42 | 43 | * 📖 [godoc](https://godoc.org/golang.org/x/tools/cmd/godoc) 44 | * 🧼 [golangci-lint](https://golangci-lint.run/) 45 | * 🧾 [Go Report Card](https://goreportcard.com/) 46 | 47 | Format your code with `gofmt`, lint with `golangci-lint`, and keep your diffs minimal. 48 | 49 |
50 | 51 | ## 📚 More Guidance 52 | 53 | For detailed workflows, commit standards, branch naming, PR templates, and more—read [AGENTS.md](./AGENTS.md). It’s the rulebook. 54 | 55 |
56 | 57 | Let’s build something great. 💪 58 | -------------------------------------------------------------------------------- /chain_info.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // GetChainInfo this endpoint retrieves various state info of the chain for the selected network. 11 | // 12 | // For more information: https://docs.whatsonchain.com/#chain-info 13 | func (c *Client) GetChainInfo(ctx context.Context) (*ChainInfo, error) { 14 | url := c.buildURL("/chain/info") 15 | return requestAndUnmarshal[ChainInfo](ctx, c, url, http.MethodGet, nil, ErrChainInfoNotFound) 16 | } 17 | 18 | // GetCirculatingSupply this endpoint retrieves the current circulating supply 19 | // 20 | // For more information: https://docs.whatsonchain.com/#get-circulating-supply 21 | func (c *Client) GetCirculatingSupply(ctx context.Context) (float64, error) { 22 | url := c.buildURL("/circulatingsupply") 23 | resp, err := requestString(ctx, c, url) 24 | if err != nil { 25 | return 0, err 26 | } 27 | return strconv.ParseFloat(strings.TrimSpace(resp), 64) 28 | } 29 | 30 | // GetChainTips this endpoint retrieves the chain tips 31 | // 32 | // For more information: https://docs.whatsonchain.com/#get-chain-tips 33 | func (c *Client) GetChainTips(ctx context.Context) ([]*ChainTip, error) { 34 | url := c.buildURL("/chain/tips") 35 | return requestAndUnmarshalSlice[*ChainTip](ctx, c, url, http.MethodGet, nil, ErrChainTipsNotFound) 36 | } 37 | 38 | // GetPeerInfo this endpoint retrieves information about peers connected to the node 39 | // 40 | // For more information: https://docs.whatsonchain.com/#get-peer-info 41 | func (c *Client) GetPeerInfo(ctx context.Context) ([]*PeerInfo, error) { 42 | url := c.buildURL("/peer/info") 43 | return requestAndUnmarshalSlice[*PeerInfo](ctx, c, url, http.MethodGet, nil, ErrPeerInfoNotFound) 44 | } 45 | -------------------------------------------------------------------------------- /.github/CODE_STANDARDS.md: -------------------------------------------------------------------------------- 1 | # ✅ Code Standards 2 | 3 | Welcome to a modern Go codebase. 4 | 5 | This library follows best-in-class practices for clarity, performance, and maintainability. 6 | 7 |
8 | 9 | ## 🎓 Effective Go 10 | 11 | We adhere to the patterns and philosophy in [Effective Go](https://golang.org/doc/effective_go.html). 12 | 13 | Stick to idiomatic code. Avoid cleverness when clarity wins. 14 | 15 |
16 | 17 | ## 🧰 Project Conventions 18 | 19 | Everything lives in our [AGENTS.md](./AGENTS.md) file. Read it. Bookmark it. Trust it. 20 | 21 | Here’s a quick overview of what you'll find: 22 | - Directory structure and related governance docs 23 | - Naming conventions for code and files 24 | - Commenting standards and templates 25 | - Development, testing, and coverage standards 26 | - Error handling practices in Go 27 | - Commit and branch naming conventions 28 | - Pull request conventions and required sections 29 | - Labeling conventions for GitHub issues/PRs 30 | - Build system and tooling overview 31 | - And more! 32 | 33 | This is intended for AI agents like [GPT](https://chatgpt.com/) and [Claude](https://claude.ai), but also for us human developers too! 34 | 35 |
36 | 37 | ## 📄 Golang Reference Material 38 | 39 | When in doubt, check the official docs: 40 | 41 | * ✨ [Effective Go](https://golang.org/doc/effective_go.html) 42 | * ⚖️ [Go Benchmarks](https://golang.org/pkg/testing/#hdr-Benchmarks) 43 | * 📖 [Go Examples](https://golang.org/pkg/testing/#hdr-Examples) 44 | * ✅ [Go Testing Guide](https://golang.org/pkg/testing/) 45 | * 📃 [godoc](https://pkg.go.dev/golang.org/x/tools/cmd/godoc) 46 | * 🔧 [gofmt](https://golang.org/cmd/gofmt/) 47 | * 📊 [golangci-lint](https://golangci-lint.run/) 48 | * 📈 [Go Report Card](https://goreportcard.com/) 49 | 50 |
51 | 52 | Happy coding — keep it clean, idiomatic, and readable. ✨ 53 | -------------------------------------------------------------------------------- /.github/.yamlfmt: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------------ 2 | # yamlfmt Configuration 3 | # 4 | # Purpose: YAML formatting configuration for the mage-x (yamlfmt) tool 5 | # 6 | # Maintainer: @mrz1836 7 | # 8 | # ------------------------------------------------------------------------------------ 9 | 10 | formatter: 11 | type: basic 12 | 13 | # Indentation settings 14 | indent: 2 15 | 16 | # Do not include document start marker (---) 17 | include_document_start: false 18 | 19 | # Preserve existing line breaks where sensible 20 | retain_line_breaks: true 21 | 22 | # Use LF line endings 23 | line_ending: lf 24 | 25 | # Maximum line length (0 = disabled) 26 | max_line_length: 0 27 | 28 | # Handle folded strings as literal 29 | scan_folded_as_literal: false 30 | 31 | # Keep arrays indented 32 | indentless_arrays: false 33 | 34 | # Remove merge tags 35 | drop_merge_tag: false 36 | 37 | # Add padding after line comments 38 | pad_line_comments: 1 39 | 40 | # File exclusions 41 | exclude: 42 | # Version control and package managers 43 | - "**/.git/**" 44 | - "**/node_modules/**" 45 | - "**/vendor/**" 46 | 47 | # Build outputs 48 | - "**/dist/**" 49 | - "**/build/**" 50 | 51 | # Coverage artifacts 52 | - "**/coverage/**" 53 | - "**/*.cover" 54 | - "**/*.cov" 55 | - "**/coverage.txt" 56 | - "**/coverage.html" 57 | 58 | # Generated code 59 | - "**/*.generated.*" 60 | - "**/*gen.go" 61 | - "**/mock**.go" 62 | - "**/*.pb.go" 63 | - "**/*.pb.gw.go" 64 | - "**/packaged.yaml" 65 | 66 | # IDE metadata 67 | - "**/.idea/**" 68 | - "**/.vscode/**" 69 | 70 | # Temporary files 71 | - "**/*.tmp" 72 | - "**/*.temp" 73 | - "**/*.swp" 74 | - "**/*.swo" 75 | - "**/*~" 76 | 77 | # Build configs 78 | - "**/.env.base" 79 | - "**/.env.custom" 80 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # Gitpod workspace configuration for go-whatsonchain 2 | # Uses magex for build automation and development tasks 3 | # This creates a one-click development environment for contributors 4 | 5 | image: gitpod/workspace-go:latest 6 | 7 | tasks: 8 | - name: setup-and-test 9 | init: | 10 | echo "🚀 Setting up go-whatsonchain development environment..." 11 | echo "📦 Installing MAGE-X build tool..." 12 | go install github.com/mrz1836/mage-x/cmd/magex@latest 13 | 14 | echo "📥 Downloading dependencies..." 15 | magex deps:download 16 | 17 | echo "🔧 Initial build..." 18 | magex build 19 | 20 | echo "✅ Running initial tests..." 21 | magex test 22 | 23 | command: | 24 | echo "===============================================" 25 | echo "🎯 Welcome to go-whatsonchain development!" 26 | echo "===============================================" 27 | echo "" 28 | echo "🛠️ Available magex commands:" 29 | echo " magex test - Run all tests" 30 | echo " magex lint - Run linters" 31 | echo " magex format:fix - Format the code" 32 | echo " magex build - Build the project" 33 | echo " magex help - List all available commands" 34 | echo "" 35 | echo "📖 Quick start:" 36 | echo " 1. Try: magex test" 37 | echo " 2. Make your changes" 38 | echo " 3. Run: magex format:fix && magex lint && magex test" 39 | echo " 4. Commit and push your changes" 40 | echo "" 41 | echo "💡 For more help: magex help" 42 | echo "===============================================" 43 | 44 | ports: 45 | - port: 8080 46 | onOpen: ignore 47 | description: Application (if needed) 48 | 49 | vscode: 50 | extensions: 51 | - golang.go 52 | - github.vscode-pull-request-github 53 | - streetsidesoftware.code-spell-checker 54 | - eamodio.gitlens 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report incorrect behavior, test failure, or unexpected output in this Go library 3 | title: "[Bug] " 4 | labels: ["bug-p3"] 5 | assignees: 6 | - mrz1836 7 | body: 8 | - type: textarea 9 | id: bug_description 10 | attributes: 11 | label: Describe the bug 12 | description: Provide a clear and concise summary of the problem or unexpected behavior. 13 | placeholder: The custom function strips the plus sign from numbers... 14 | validations: 15 | required: true 16 | 17 | - type: textarea 18 | id: reproduction_steps 19 | attributes: 20 | label: Steps to reproduce 21 | description: Provide minimal steps or code snippets to reproduce the issue. 22 | placeholder: | 23 | 1. Call package.Func("input") 24 | 2. Observe that the result is "input" (missing 'symbol') 25 | validations: 26 | required: true 27 | 28 | - type: textarea 29 | id: expected_behavior 30 | attributes: 31 | label: Expected behavior 32 | description: Describe what you expected to happen instead. 33 | placeholder: I expected the result to be "symbol+input" since the plus sign is allowed. 34 | validations: 35 | required: true 36 | 37 | - type: textarea 38 | id: environment 39 | attributes: 40 | label: Environment details 41 | description: Provide version information and your Go setup to help debug. 42 | placeholder: | 43 | - Go version: go1.24.3 44 | - OS: macOS 14.5 (arm64) 45 | - Library version/commit: v1.3.4 or commit 88aef9c 46 | validations: 47 | required: false 48 | 49 | - type: textarea 50 | id: additional_context 51 | attributes: 52 | label: Additional context 53 | description: Add any logs, test output, or relevant code here. 54 | placeholder: | 55 | Output from `go test -v ./...` 56 | Any relevant stack traces, links to code, or affected functions. 57 | validations: 58 | required: false 59 | -------------------------------------------------------------------------------- /btc_specific_test.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | // mockHTTPEmpty for simple mocking 11 | type mockHTTPEmpty struct{} 12 | 13 | // Do is a mock http request 14 | func (m *mockHTTPEmpty) Do(_ *http.Request) (*http.Response, error) { 15 | resp := new(http.Response) 16 | resp.StatusCode = http.StatusOK 17 | return resp, nil 18 | } 19 | 20 | // TestBTCService_Interface tests that Client implements BTCService interface 21 | func TestBTCService_Interface(t *testing.T) { 22 | t.Parallel() 23 | 24 | // Test that Client implements BTCService interface 25 | var _ BTCService = (*Client)(nil) 26 | 27 | // Test that interface can be used with client 28 | client := newMockClientBTC(&mockHTTPEmpty{}) 29 | assert.NotNil(t, client) 30 | 31 | // Verify client is of the correct type that implements the interface 32 | btcService, ok := interface{}(client).(BTCService) 33 | assert.True(t, ok, "Client should implement BTCService interface") 34 | assert.NotNil(t, btcService) 35 | } 36 | 37 | // TestBTCService_ChainSpecific tests BTC chain-specific behavior 38 | func TestBTCService_ChainSpecific(t *testing.T) { 39 | t.Parallel() 40 | 41 | // Test BTC chain creation 42 | client := newMockClientBTC(&mockHTTPEmpty{}) 43 | assert.NotNil(t, client) 44 | assert.Equal(t, ChainBTC, client.Chain()) 45 | 46 | // Test that BTC service interface is still satisfied 47 | var _ BTCService = client 48 | } 49 | 50 | // TestBTCService_InterfaceCompletion tests interface is properly defined 51 | func TestBTCService_InterfaceCompletion(t *testing.T) { 52 | t.Parallel() 53 | 54 | // Test that the interface type is defined correctly 55 | client := newMockClientBTC(&mockHTTPEmpty{}) 56 | 57 | // Test casting to BTCService 58 | btcService := BTCService(client) 59 | assert.NotNil(t, btcService) 60 | } 61 | 62 | // TestBTCService_ChainComparison tests BSV vs BTC chain differences 63 | func TestBTCService_ChainComparison(t *testing.T) { 64 | t.Parallel() 65 | 66 | // Test BSV vs BTC client differences 67 | bsvClient := newMockClientBSV(&mockHTTPEmpty{}) 68 | btcClient := newMockClientBTC(&mockHTTPEmpty{}) 69 | 70 | assert.Equal(t, ChainBSV, bsvClient.Chain()) 71 | assert.Equal(t, ChainBTC, btcClient.Chain()) 72 | } 73 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new_function.yml: -------------------------------------------------------------------------------- 1 | name: New Function Request 2 | description: Request the addition of a new Go function with full test, benchmark, and documentation coverage. 3 | title: "[Function] Add " 4 | labels: ["feature", "test", "documentation"] 5 | assignees: 6 | - mrz1836 7 | body: 8 | - type: input 9 | id: function_name 10 | attributes: 11 | label: Function Name 12 | description: Name of the new function to implement 13 | placeholder: e.g., PhoneNumber 14 | validations: 15 | required: true 16 | 17 | - type: textarea 18 | id: function_description 19 | attributes: 20 | label: Function Description 21 | description: Describe what the function should do (include allowed characters, formats, etc.) 22 | placeholder: This function should return a sanitized string containing only digits and a plus sign... 23 | validations: 24 | required: true 25 | 26 | - type: checkboxes 27 | id: implementation_checklist 28 | attributes: 29 | label: Implementation Checklist 30 | description: Please complete each step below to ensure the function is implemented thoroughly. 31 | options: 32 | - label: 🧠 Follow existing patterns and add function alphabetically to all applicable files 33 | - label: ✍️ Add GoDoc-style comment to the function 34 | - label: ✅ Create test function in `sanitize_test.go` (covering USA, international, mixed-format numbers) 35 | - label: 📘 Create example function in `sanitize_example_test.go` 36 | - label: 🧪 Create benchmark function in `sanitize_benchmark_test.go` 37 | - label: 🧾 Add benchmark results to the performance table in `README.md` 38 | - label: 🧩 Add function name and description to the function list in `README.md` (alphabetically) 39 | - label: 📎 Add real-world example to `examples/example.go` (alphabetically) 40 | - label: 🧬 Create fuzz test in `sanitize_fuzz_test.go` (ensure only allowed characters are output) 41 | 42 | - type: textarea 43 | id: notes 44 | attributes: 45 | label: Additional Notes or Edge Cases 46 | description: Add any extra information, caveats, or unusual cases the developer should consider 47 | placeholder: e.g., Handle inputs with international dial codes, or double plus signs 48 | validations: 49 | required: false 50 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # 🏗️ How We Build Together 2 | 3 | Welcome to our humble Go library. We value good ideas, clear communication, and contributions from people who care about great code. This document sets the tone for how we collaborate. 4 | 5 |
6 | 7 | --- 8 | 9 |
10 | 11 | ### 🧠 Final Decisions 12 | 13 | Project maintainers make the final call. They can revisit and revise past decisions. It keeps things moving. 14 | 15 |
16 | 17 | ### 🧰 Contributions Welcome 18 | 19 | Pitch in! Collaboration is a team sport. Don't leave others carrying your weight forever. 20 | 21 |
22 | 23 | ### 🎯 Choose Your Adventure 24 | 25 | Everyone can tackle whatever challenge they feel ready for. Grab an issue and go! 26 | 27 |
28 | 29 | ### 🏗️ Earned Roles 30 | 31 | Project influence is earned through consistent, quality contributions. Titles mean less than impact. 32 | 33 |
34 | 35 | ### 🧪 Better > Worse 36 | 37 | Good code replaces not-so-good code. Technical merit always wins. 38 | 39 |
40 | 41 | ### 🔒 Keep It On Topic 42 | 43 | This is a space for building software. Let's stay focused. 44 | 45 |
46 | 47 | ### ⚖️ Take It Elsewhere 48 | 49 | If something’s not about the project, take it to another channel. Let's not derail progress. 50 | 51 |
52 | 53 | ### 🫶 We See People, Not Labels 54 | 55 | Your identity—race, gender, beliefs, background—is irrelevant here. Code speaks louder. 56 | 57 |
58 | 59 | ### 💬 Debate Ideas, Not People 60 | 61 | Challenge code. Not humans. No exceptions. 62 | 63 |
64 | 65 | ### 🔍 Be Clear 66 | 67 | If your idea isn't clear, expect questions. If it stays unclear, expect crickets. Help us help you. 68 | 69 |
70 | 71 | ### 🚫 Illegal is Illegal 72 | 73 | Anything illegal outside the project is illegal here too. Keep it clean. 74 | 75 |
76 | 77 | ### 📦 Project-Only Focus 78 | 79 | This code of merit only applies to what happens *in* the project. 80 | 81 |
82 | 83 | ### ✅ Participation = Agreement 84 | 85 | By contributing, you're saying, "Yep, I'm in." 86 | 87 |
88 | 89 | ### 🎯 Stick to the Mission 90 | 91 | Trying to shift this project away from its purpose? That's not cool and won't be allowed. 92 | 93 |
94 | 95 | --- 96 | 97 |
98 | 99 | Thanks for keeping things fun, focused, and respectful. Let's build something awesome together! 100 | -------------------------------------------------------------------------------- /.github/actions/upload-statistics/action.yml: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------------ 2 | # Upload Statistics Composite Action (GoFortress) 3 | # 4 | # Purpose: Standardize all artifact uploads with consistent configuration and 5 | # security best practices. Provides a unified interface for uploading statistics, 6 | # cache data, test results, and other workflow artifacts. 7 | # 8 | # Features: 9 | # - Wraps actions/upload-artifact with pinned security version 10 | # - Maintains exact version pinning for security compliance 11 | # - Preserves retention-days flexibility per workflow needs 12 | # - Consistent naming and configuration across all uploads 13 | # - Always runs regardless of job success/failure status 14 | # 15 | # Usage: 16 | # - uses: ./.github/actions/upload-statistics 17 | # with: 18 | # artifact-name: "cache-stats-pre-commit" 19 | # artifact-path: "cache-stats-pre-commit.json" 20 | # retention-days: "1" 21 | # 22 | # Maintainer: @mrz1836 23 | # 24 | # ------------------------------------------------------------------------------------ 25 | 26 | name: "Upload Statistics" 27 | description: "Upload statistics artifacts with standardized configuration and security practices" 28 | 29 | inputs: 30 | artifact-name: 31 | description: "Name of the artifact (will be displayed in GitHub UI)" 32 | required: true 33 | artifact-path: 34 | description: "Path to the artifact file(s) to upload" 35 | required: true 36 | retention-days: 37 | description: "Number of days to retain the artifact (1-90 days)" 38 | required: false 39 | default: "1" 40 | if-no-files-found: 41 | description: "Behavior when no files match the path (warn, error, ignore)" 42 | required: false 43 | default: "ignore" 44 | compression-level: 45 | description: "Compression level for the artifact (0-9, 6 is default)" 46 | required: false 47 | default: "6" 48 | 49 | runs: 50 | using: "composite" 51 | steps: 52 | # -------------------------------------------------------------------- 53 | # Upload artifact with standardized security and configuration 54 | # -------------------------------------------------------------------- 55 | - name: 📤 Upload ${{ inputs.artifact-name }} 56 | if: always() # Always run to capture data even on job failure 57 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 58 | with: 59 | name: ${{ inputs.artifact-name }} 60 | path: ${{ inputs.artifact-path }} 61 | retention-days: ${{ inputs.retention-days }} 62 | if-no-files-found: ${{ inputs.if-no-files-found }} 63 | compression-level: ${{ inputs.compression-level }} 64 | env: 65 | # Ensure consistent behavior across all environments 66 | ACTIONS_UPLOAD_RETRY_COUNT: 3 67 | -------------------------------------------------------------------------------- /.github/tech-conventions/README.md: -------------------------------------------------------------------------------- 1 | # Technical Conventions 2 | 3 |

4 | 5 | ## 📚 Convention Categories 6 | 7 | ### 🚀 Core Development 8 | 9 | **[Go Essentials](go-essentials.md)** 10 | Non-negotiable Go development practices including context-first design, interface philosophy, goroutine discipline, error handling, and performance guidelines. 11 | 12 | **[Testing Standards](testing-standards.md)** 13 | Comprehensive testing guidelines covering unit tests, table-driven tests, fuzz testing, code coverage requirements, and testing best practices. 14 | 15 | **[Commenting & Documentation](commenting-documentation.md)** 16 | Standards for code comments, function documentation, package-level docs, Markdown formatting, and maintaining clear, purposeful documentation. 17 | 18 |

19 | 20 | ### 🔄 Version Control & Collaboration 21 | 22 | **[Commit & Branch Conventions](commit-branch-conventions.md)** 23 | Git commit message format, branch naming standards, and version control best practices for maintaining clean repository history. 24 | 25 | **[Pull Request Guidelines](pull-request-guidelines.md)** 26 | Structured approach to creating and reviewing pull requests, including required sections, review etiquette, and merging strategies. 27 | 28 | **[Release Workflow & Versioning](release-versioning.md)** 29 | Semantic versioning practices, release tooling with goreleaser, changelog management, and automated release processes. 30 | 31 |

32 | 33 | ### 🏷️ Project Management 34 | 35 | **[Labeling Conventions](labeling-conventions.md)** 36 | GitHub label system for categorizing issues and PRs, including standard labels, usage guidelines, and automated labeling. 37 | 38 | ### 🤖 AI Usage & Assistant Guidelines 39 | **[AI Compliance](ai-compliance.md)** 40 | Guide to AI assistant configuration files and where to find standards for AI-assisted development. 41 | 42 |

43 | 44 | ### 🔧 Infrastructure & Quality 45 | 46 | **[Dependency Management](dependency-management.md)** 47 | Go modules management, security scanning, version control practices, and maintaining healthy dependencies. 48 | 49 | **[Security Practices](security-practices.md)** 50 | Security-first development, vulnerability reporting, security tools, and following OpenSSF best practices. 51 | 52 | **[Pre-commit Hooks](pre-commit.md)** 53 | Pure Go pre-commit framework with 17x faster execution than Python alternatives, providing automated code quality checks. 54 | 55 | **[GitHub Workflows Development](github-workflows.md)** 56 | Creating and maintaining GitHub Actions workflows with security, reliability, and performance in mind. 57 | 58 |

59 | 60 | ### 🏗️ Build & Project Setup 61 | 62 | **[MAGE-X Build Automation](mage-x.md)** 63 | Zero-boilerplate build automation system with 150+ built-in commands that replaces Makefiles. Includes installation, configuration, command reference, and migration guide. 64 | -------------------------------------------------------------------------------- /.github/actions/parse-env/action.yml: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------------ 2 | # Parse Environment Variables Composite Action (GoFortress) 3 | # 4 | # Purpose: Parse JSON environment variables into GITHUB_ENV, eliminating duplicate 5 | # parsing logic across all GoFortress workflows. 6 | # 7 | # Features: 8 | # - JSON validation with clear error messages 9 | # - Preserves exact output format for compatibility 10 | # - Maintains security boundaries (no secret exposure) 11 | # - Follows existing fortress patterns (emoji prefixes, structured logging) 12 | # - Error handling matches existing implementations 13 | # 14 | # Usage: 15 | # - uses: ./.github/actions/parse-env 16 | # with: 17 | # env-json: ${{ inputs.env-json }} 18 | # 19 | # Maintainer: @mrz1836 20 | # 21 | # ------------------------------------------------------------------------------------ 22 | 23 | name: "Parse Environment Variables" 24 | description: "Parse JSON environment variables into GITHUB_ENV with validation and error handling" 25 | 26 | inputs: 27 | env-json: 28 | description: "JSON string of environment variables to parse and set" 29 | required: true 30 | 31 | runs: 32 | using: "composite" 33 | steps: 34 | # -------------------------------------------------------------------- 35 | # Parse and validate JSON environment variables 36 | # -------------------------------------------------------------------- 37 | - name: 🔧 Parse environment variables 38 | shell: bash 39 | run: | 40 | echo "📋 Setting environment variables..." 41 | 42 | # Get the input JSON 43 | ENV_JSON='${{ inputs.env-json }}' 44 | 45 | # Validate JSON format before processing 46 | if ! echo "$ENV_JSON" | jq empty 2>/dev/null; then 47 | echo "❌ ERROR: Invalid JSON format in env-json input!" >&2 48 | echo " Please ensure the input is valid JSON." >&2 49 | exit 1 50 | fi 51 | 52 | # Check if JSON is empty or null 53 | if [[ -z "$ENV_JSON" ]] || [[ "$ENV_JSON" == "null" ]] || [[ "$ENV_JSON" == "{}" ]]; then 54 | echo "❌ ERROR: Empty or null JSON provided!" >&2 55 | echo " Please provide valid environment variables in JSON format." >&2 56 | exit 1 57 | fi 58 | 59 | # Parse and set each variable (exact pattern from existing workflows) 60 | echo "$ENV_JSON" | jq -r 'to_entries | .[] | "\(.key)=\(.value)"' | while IFS='=' read -r key value; do 61 | # Validate key name (basic safety check) 62 | if [[ -z "$key" ]]; then 63 | echo "⚠️ WARNING: Skipping empty variable name" >&2 64 | continue 65 | fi 66 | 67 | # Set the environment variable 68 | echo "$key=$value" >> $GITHUB_ENV 69 | done 70 | 71 | # Count and report variables set 72 | VAR_COUNT=$(echo "$ENV_JSON" | jq 'keys | length') 73 | echo "✅ Environment variables parsed successfully ($VAR_COUNT variables)" 74 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: ["master", "main"] # Trigger on pushes to both master and main branches 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: ["master", "main"] 14 | schedule: 15 | - cron: "0 8 * * 1" # Every Monday at 08:00 UTC 16 | 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 19 | cancel-in-progress: true 20 | 21 | # Security: Restrictive default permissions with job-level overrides for least privilege access 22 | permissions: 23 | contents: read 24 | 25 | jobs: 26 | analyze: 27 | name: Analyze 28 | runs-on: ubuntu-latest 29 | 30 | permissions: 31 | actions: read 32 | contents: read 33 | security-events: write # Required for CodeQL to upload results 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | # Override automatic language detection by changing the below list 39 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 40 | language: ["go"] 41 | # Learn more... 42 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 43 | 44 | steps: 45 | - name: Checkout repository 46 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 47 | 48 | # Initializes the CodeQL tools for scanning. 49 | - name: Initialize CodeQL 50 | uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 51 | with: 52 | languages: ${{ matrix.language }} 53 | # If you wish to specify custom queries, you can do so here or in a config file. 54 | # By default, queries listed here will override any specified in a config file. 55 | # Prefix the list here with "+" to use these queries and those in the config file. 56 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 https://git.io/JvXDl 65 | 66 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 67 | # and modify them (or add more) to build your code if your project 68 | # uses a compiled language 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 72 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # Make sure to check the documentation at http://goreleaser.com 2 | # --------------------------- 3 | # General 4 | # --------------------------- 5 | version: 2 6 | 7 | before: 8 | hooks: 9 | - | 10 | sh -c ' 11 | if [ "$SKIP_GORELEASER_TESTS" = "true" ]; then 12 | echo "Skipping tests (SKIP_GORELEASER_TESTS=true)" 13 | else 14 | magex test 15 | fi 16 | ' 17 | changelog: 18 | sort: asc 19 | filters: 20 | exclude: 21 | - "^.vscode:" 22 | - "^test:" 23 | 24 | # --------------------------- 25 | # Builder 26 | # --------------------------- 27 | builds: 28 | - env: 29 | - CGO_ENABLED=0 30 | goos: 31 | - linux 32 | - windows 33 | - darwin 34 | skip: true 35 | 36 | # --------------------------- 37 | # Archives 38 | # --------------------------- 39 | archives: 40 | - id: go-whatsonchain 41 | name_template: >- 42 | {{- .ProjectName }}_{{- .Version }}_{{- .Os }}_{{- .Arch }} 43 | files: 44 | - LICENSE 45 | - README.md 46 | 47 | # --------------------------- 48 | # Checksum 49 | # --------------------------- 50 | checksum: 51 | name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" 52 | algorithm: sha256 53 | 54 | # --------------------------- 55 | # Github Release 56 | # --------------------------- 57 | release: 58 | prerelease: "false" 59 | name_template: "Release v{{.Version}}" 60 | 61 | # --------------------------- 62 | # Announce 63 | # --------------------------- 64 | announce: 65 | # See more at: https://goreleaser.com/customization/announce/#slack 66 | slack: 67 | enabled: false 68 | message_template: "{{ .ProjectName }} {{ .Tag }} is out! Changelog: https://github.com/{{ .GitOwner }}/{{ .ProjectName }}/releases/tag/{{ .Tag }}" 69 | channel: "#test_slack" 70 | # username: '' 71 | # icon_emoji: '' 72 | # icon_url: '' 73 | 74 | # See more at: https://goreleaser.com/customization/announce/#twitter 75 | twitter: 76 | enabled: false 77 | message_template: "{{ .ProjectName }} {{ .Tag }} is out!" 78 | 79 | # See more at: https://goreleaser.com/customization/announce/#discord 80 | discord: 81 | enabled: false 82 | message_template: "{{ .ProjectName }} {{ .Tag }} is out!" 83 | # Defaults to `GoReleaser` 84 | author: "" 85 | # Defaults to `3888754` - the grey-ish from goreleaser 86 | color: "" 87 | # Defaults to `https://goreleaser.com/static/avatar.png` 88 | icon_url: "" 89 | 90 | # See more at: https://goreleaser.com/customization/announce/#reddit 91 | reddit: 92 | enabled: false 93 | # Application ID for Reddit Application 94 | application_id: "" 95 | # Username for your Reddit account 96 | username: "" 97 | # Defaults to `{{ .GitURL }}/releases/tag/{{ .Tag }}` 98 | # url_template: 'https://github.com/{{ .GitOwner }}/{{ .ProjectName }}/releases/tag/{{ .Tag }}' 99 | # Defaults to `{{ .ProjectName }} {{ .Tag }} is out!` 100 | title_template: "{{ .ProjectName }} {{ .Tag }} is out!" 101 | -------------------------------------------------------------------------------- /blocks.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | // GetBlockByHash this endpoint retrieves block details with given hash. 10 | // 11 | // For more information: https://docs.whatsonchain.com/#get-by-hash 12 | func (c *Client) GetBlockByHash(ctx context.Context, hash string) (*BlockInfo, error) { 13 | url := c.buildURL("/block/hash/%s", hash) 14 | return requestAndUnmarshal[BlockInfo](ctx, c, url, http.MethodGet, nil, ErrBlockNotFound) 15 | } 16 | 17 | // GetBlockByHeight this endpoint retrieves block details with given block height. 18 | // 19 | // For more information: https://docs.whatsonchain.com/#get-by-height 20 | func (c *Client) GetBlockByHeight(ctx context.Context, height int64) (*BlockInfo, error) { 21 | url := c.buildURL("/block/height/%d", height) 22 | return requestAndUnmarshal[BlockInfo](ctx, c, url, http.MethodGet, nil, ErrBlockNotFound) 23 | } 24 | 25 | // GetBlockPages if the block has more than 1000 transactions the page URIs will 26 | // be provided in the "pages element" when getting a block by hash or height. 27 | // 28 | // For more information: https://docs.whatsonchain.com/#get-block-pages 29 | func (c *Client) GetBlockPages(ctx context.Context, hash string, page int) (BlockPagesInfo, error) { 30 | url := c.buildURL("/block/hash/%s/page/%d", hash, page) 31 | return requestAndUnmarshalSlice[string](ctx, c, url, http.MethodGet, nil, ErrBlockNotFound) 32 | } 33 | 34 | // GetHeaderByHash this endpoint retrieves block header details with given hash. 35 | // 36 | // For more information: https://docs.whatsonchain.com/#get-header-by-hash 37 | func (c *Client) GetHeaderByHash(ctx context.Context, hash string) (*BlockInfo, error) { 38 | url := c.buildURL("/block/%s/header", hash) 39 | return requestAndUnmarshal[BlockInfo](ctx, c, url, http.MethodGet, nil, ErrBlockNotFound) 40 | } 41 | 42 | // GetHeaders this endpoint retrieves last 10 block headers. 43 | // 44 | // For more information: https://docs.whatsonchain.com/#get-headers 45 | func (c *Client) GetHeaders(ctx context.Context) ([]*BlockInfo, error) { 46 | url := c.buildURL("/block/headers") 47 | return requestAndUnmarshalSlice[*BlockInfo](ctx, c, url, http.MethodGet, nil, ErrHeadersNotFound) 48 | } 49 | 50 | // GetHeaderBytesFileLinks this endpoint retrieves header bytes file links. 51 | // 52 | // For more information: https://docs.whatsonchain.com/#get-header-bytes 53 | func (c *Client) GetHeaderBytesFileLinks(ctx context.Context) (*HeaderBytesResource, error) { 54 | url := c.buildURL("/block/headers/resources") 55 | return requestAndUnmarshal[HeaderBytesResource](ctx, c, url, http.MethodGet, nil, ErrHeadersNotFound) 56 | } 57 | 58 | // GetLatestHeaderBytes this endpoint retrieves latest header bytes. 59 | // 60 | // For more information: https://docs.whatsonchain.com/#get-latest-headers 61 | func (c *Client) GetLatestHeaderBytes(ctx context.Context, count int) (string, error) { 62 | path := "/block/headers/latest" 63 | if count > 0 { 64 | path = fmt.Sprintf("%s?count=%d", path, count) 65 | } 66 | url := c.buildURL(path) 67 | resp, err := requestString(ctx, c, url) 68 | if err != nil { 69 | return "", err 70 | } 71 | if len(resp) == 0 { 72 | return "", ErrHeadersNotFound 73 | } 74 | return resp, nil 75 | } 76 | -------------------------------------------------------------------------------- /.github/scripts/parse-test-label.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # ------------------------------------------------------------------------------------ 3 | # Parse Test Label Script 4 | # 5 | # Helper function to generate human-readable test labels from artifact names. 6 | # Sourced by workflow steps that need consistent test labeling. 7 | # 8 | # Usage: parse_test_label "artifact-name" "jsonl-filename" 9 | # Output: "Unit Tests (Ubuntu, Go 1.22)" or similar 10 | # ------------------------------------------------------------------------------------ 11 | 12 | parse_test_label() { 13 | local artifact_name="$1" 14 | local jsonl_name="$2" 15 | 16 | # Determine test type from artifact prefix or JSONL name 17 | local test_type="Tests" 18 | if [[ "$artifact_name" == test-results-fuzz-* ]] || [[ "$jsonl_name" == *fuzz* ]]; then 19 | test_type="Fuzz Tests" 20 | elif [[ "$artifact_name" == ci-results-* ]]; then 21 | test_type="Unit Tests" 22 | fi 23 | 24 | # Extract OS from artifact name 25 | local os_name="" 26 | if [[ "$artifact_name" =~ ubuntu ]]; then 27 | os_name="Ubuntu" 28 | elif [[ "$artifact_name" =~ windows ]]; then 29 | os_name="Windows" 30 | elif [[ "$artifact_name" =~ macos ]]; then 31 | os_name="macOS" 32 | fi 33 | 34 | # Extract Go version (last segment like "1.22", "1.24.x", or "go1.22") 35 | local go_version="" 36 | go_version=$(echo "$artifact_name" | grep -oE '[0-9]+\.[0-9]+(\.[x0-9]+)?' | tail -1 || echo "") 37 | 38 | # Build label 39 | if [[ -n "$os_name" && -n "$go_version" ]]; then 40 | echo "$test_type ($os_name, Go $go_version)" 41 | elif [[ -n "$os_name" ]]; then 42 | echo "$test_type ($os_name)" 43 | elif [[ -n "$go_version" ]]; then 44 | echo "$test_type (Go $go_version)" 45 | else 46 | echo "$test_type" 47 | fi 48 | } 49 | 50 | # Copy CI artifact file with artifact directory prefix for unique naming 51 | # Usage: copy_ci_artifact "source_file" ["ci"|"fuzz"] 52 | # Example: copy_ci_artifact "/path/to/ci-artifacts/artifact-name/.mage-x/ci-results.jsonl" "ci" 53 | copy_ci_artifact() { 54 | local file="$1" 55 | local prefix="${2:-ci}" 56 | 57 | # Validate input file exists 58 | if [[ ! -f "$file" ]]; then 59 | echo "⚠️ Warning: File not found: $file" >&2 60 | return 1 61 | fi 62 | 63 | # Extract artifact directory name for unique naming 64 | local parent_dir=$(dirname "$file") 65 | local parent_basename=$(basename "$parent_dir") 66 | local artifact_dir 67 | 68 | # Detect which structure we have by checking parent directory 69 | # Expected: *-artifacts/ARTIFACT_NAME/.mage-x/ci-results.jsonl 70 | if [[ "$parent_basename" == ".mage-x" ]]; then 71 | # Expected structure: use grandparent as artifact dir 72 | artifact_dir=$(dirname "$parent_dir" | xargs basename) 73 | else 74 | # Fallback: parent is the artifact dir (not grandparent) 75 | echo " Warning: Unexpected artifact structure for: $file" >&2 76 | artifact_dir="$parent_basename" 77 | fi 78 | local filename=$(basename "$file") 79 | local dest="${prefix}-${artifact_dir}-${filename}" 80 | 81 | echo "Copying $prefix results $file to ./$dest" 82 | if ! cp "$file" "./$dest"; then 83 | echo "⚠️ Warning: Failed to copy $file to $dest" >&2 84 | return 1 85 | fi 86 | } 87 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # 🔐 Security Policy 2 | 3 | Security is a priority. We maintain a proactive stance to identify and fix vulnerabilities in **go-whatsonchain**. 4 | 5 |
6 | 7 | ## 🛠️ Supported & Maintained Versions 8 | Any released version of **go-whatsonchain** that is not marked as deprecated is actively supported and maintained. 9 | 10 |
11 | 12 | ## 📨 Reporting a Vulnerability 13 | 14 | If you’ve found a security issue, **please don’t open a public issue or PR**. 15 | 16 | Instead, send a private email to: 17 | 📧 [go-whatsonchain@mrz1818.com](mailto:go-whatsonchain@mrz1818.com) 18 | 19 | Include the following: 20 | 21 | * 🕵️ Description of the issue and its impact 22 | * 🧪 Steps to reproduce or a working PoC 23 | * 🔧 Any known workarounds or mitigations 24 | 25 | We welcome responsible disclosures from researchers, vendors, users, and curious tinkerers alike. 26 | 27 |
28 | 29 | ## 📅 What to Expect 30 | 31 | * 🧾 **Acknowledgment** within 72 hours 32 | * 📢 **Status updates** every 5 business days 33 | * ✅ **Resolution target** of 30 days (for confirmed vulnerabilities) 34 | 35 | Prefer encrypted comms? Let us know in your initial email—we’ll reply with our PGP public key. 36 | All official security responses are signed with it. 37 | 38 |
39 | 40 | ## 🧪 Security Tooling 41 | 42 | We regularly scan for known vulnerabilities using: 43 | 44 | * [`govulncheck`](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck): Checks Go code and dependencies for known vulnerabilities using the Go vulnerability database. 45 | * [`ask nancy`](https://github.com/sonatype-nexus-community/nancy): As part of our CI (see `fortress.yml`), we run [nancy](https://github.com/sonatype-nexus-community/nancy) to check Go dependencies for vulnerabilities against the OSS Index. This helps us catch issues in third-party packages early. 46 | * [`gitleaks`](https://github.com/gitleaks/gitleaks): Scans the repository for sensitive data or secrets that may have been accidentally committed to the codebase. 47 | 48 | Want to run these yourself? 49 | 50 | ```sh 51 | magex deps:audit 52 | # or run nancy via the CI workflow 53 | ``` 54 | 55 | This will check your local build for known issues in Go modules and dependencies. 56 | 57 |
58 | 59 | ## 🛡️ Security Standards 60 | 61 | We follow the [OpenSSF](https://openssf.org) best practices to ensure this repository remains compliant with industry‑standard open source security guidelines. 62 | 63 |
64 | 65 | ## 🛠️ GitHub Security Workflows 66 | 67 | To proactively protect this repository, we use several automated GitHub workflows: 68 | 69 | - **[CodeQL Analysis](./.github/workflows/codeql-analysis.yml)**: Scans the codebase for security vulnerabilities and coding errors using GitHub's CodeQL engine on every push and pull request to the `main/master` branch. 70 | - **[OpenSSF Scorecard](./.github/workflows/scorecard.yml)**: Periodically evaluates the repository against OpenSSF Scorecard checks, providing insights and recommendations for improving supply chain security and best practices. 71 | 72 | These workflows help us identify, remediate, and prevent security issues as early as possible in the development lifecycle. For more details, see the workflow files in the [`.github/workflows/`](.github/workflows) directory. 73 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import "errors" 4 | 5 | // ErrAddressNotFound is when an address is not found 6 | var ErrAddressNotFound = errors.New("address not found") 7 | 8 | // ErrBlockNotFound is when a block is not found 9 | var ErrBlockNotFound = errors.New("block not found") 10 | 11 | // ErrChainInfoNotFound is when the chain info is not found 12 | var ErrChainInfoNotFound = errors.New("chain info not found") 13 | 14 | // ErrChainTipsNotFound is when the chain tips are not found 15 | var ErrChainTipsNotFound = errors.New("chain tips not found") 16 | 17 | // ErrPeerInfoNotFound is when the peer info is not found 18 | var ErrPeerInfoNotFound = errors.New("peer info not found") 19 | 20 | // ErrExchangeRateNotFound is when the exchange rate is not found 21 | var ErrExchangeRateNotFound = errors.New("exchange rate not found") 22 | 23 | // ErrMempoolInfoNotFound is when the mempool info is not found 24 | var ErrMempoolInfoNotFound = errors.New("mempool info not found") 25 | 26 | // ErrHeadersNotFound is when the headers are not found 27 | var ErrHeadersNotFound = errors.New("headers not found") 28 | 29 | // ErrScriptNotFound is when a script is not found 30 | var ErrScriptNotFound = errors.New("script not found") 31 | 32 | // ErrTransactionNotFound is when a transaction is not found 33 | var ErrTransactionNotFound = errors.New("transaction not found") 34 | 35 | // ErrMaxAddressesExceeded is when the max addresses limit is exceeded 36 | var ErrMaxAddressesExceeded = errors.New("max limit of addresses exceeded") 37 | 38 | // ErrMaxScriptsExceeded is when the max scripts limit is exceeded 39 | var ErrMaxScriptsExceeded = errors.New("max limit of scripts exceeded") 40 | 41 | // ErrBroadcastFailed is when transaction broadcasting fails 42 | var ErrBroadcastFailed = errors.New("error broadcasting transaction") 43 | 44 | // ErrMaxTransactionsExceeded is when the max transactions limit is exceeded 45 | var ErrMaxTransactionsExceeded = errors.New("max transactions limit exceeded") 46 | 47 | // ErrMaxPayloadSizeExceeded is when the max payload size is exceeded 48 | var ErrMaxPayloadSizeExceeded = errors.New("max overall payload size exceeded") 49 | 50 | // ErrMaxTransactionSizeExceeded is when the max single transaction size is exceeded 51 | var ErrMaxTransactionSizeExceeded = errors.New("max transaction size exceeded") 52 | 53 | // ErrMaxUTXOsExceeded is when the max UTXO limit is exceeded 54 | var ErrMaxUTXOsExceeded = errors.New("max limit of UTXOs exceeded") 55 | 56 | // ErrMaxRawTransactionsExceeded is when the max raw transactions limit is exceeded 57 | var ErrMaxRawTransactionsExceeded = errors.New("max limit of raw transactions exceeded") 58 | 59 | // ErrMissingRequest is when a request is missing 60 | var ErrMissingRequest = errors.New("missing request") 61 | 62 | // ErrBadRequest is when a request is invalid 63 | var ErrBadRequest = errors.New("bad request") 64 | 65 | // ErrBSVChainRequired is when a BSV-only operation is attempted on a non-BSV chain 66 | var ErrBSVChainRequired = errors.New("operation is only available for BSV chain") 67 | 68 | // ErrBTCChainRequired is when a BTC-only operation is attempted on a non-BTC chain 69 | var ErrBTCChainRequired = errors.New("operation is only available for BTC chain") 70 | 71 | // ErrTokenNotFound is when a token is not found 72 | var ErrTokenNotFound = errors.New("token not found") 73 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[go.mod]": { 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports": "explicit" 5 | }, 6 | "editor.formatOnSave": true 7 | }, 8 | "[go]": { 9 | "editor.codeActionsOnSave": { 10 | "source.organizeImports": "explicit" 11 | }, 12 | "editor.defaultFormatter": "golang.go", 13 | "editor.formatOnSave": true 14 | }, 15 | "[go][go.mod]": { 16 | "editor.codeActionsOnSave": { 17 | "source.organizeImports": "explicit" 18 | } 19 | }, 20 | "[yaml]": { 21 | "editor.defaultFormatter": "redhat.vscode-yaml" 22 | }, 23 | "files.associations": { 24 | "application.yaml": "yaml-cloudformation" 25 | }, 26 | "go.addTags": { 27 | "options": "json=omitempty", 28 | "promptForTags": false, 29 | "tags": "json", 30 | "transform": "snakecase" 31 | }, 32 | "go.coverOnSave": false, 33 | "go.coverageDecorator": { 34 | "coveredHighlightColor": "rgba(64,128,64,0.5)", 35 | "type": "gutter", 36 | "uncoveredHighlightColor": "rgba(128,64,64,0.25)" 37 | }, 38 | "go.delveConfig": { 39 | "debugAdapter": "dlv-dap", 40 | "dlvLoadConfig": { 41 | "followPointers": true, 42 | "maxArrayValues": 64, 43 | "maxStringLen": 256, 44 | "maxStructFields": -1, 45 | "maxVariableRecurse": 1 46 | }, 47 | "showGlobalVariables": false 48 | }, 49 | "go.enableCodeLens": { 50 | "runtest": true 51 | }, 52 | "go.inlayHints.assignVariableTypes": true, 53 | "go.inlayHints.compositeLiteralFields": true, 54 | "go.inlayHints.compositeLiteralTypes": true, 55 | "go.inlayHints.constantValues": true, 56 | "go.inlayHints.functionTypeParameters": true, 57 | "go.inlayHints.parameterNames": true, 58 | "go.inlayHints.rangeVariableTypes": true, 59 | "go.lintFlags": [ 60 | "--fast", 61 | "--timeout=5m" 62 | ], 63 | "go.lintOnSave": "package", 64 | "go.lintTool": "golangci-lint", 65 | "go.testExplorerPackages": ".*", 66 | "go.testFlags": [ 67 | "-v", 68 | "-race", 69 | "-count=1" 70 | ], 71 | "go.toolsManagement.autoUpdate": true, 72 | "go.useLanguageServer": true, 73 | "gopls": { 74 | "build.directoryFilters": [ 75 | "-node_modules", 76 | "-vendor", 77 | "-.git", 78 | "-dist", 79 | "-build", 80 | "-.mage-cache" 81 | ], 82 | "formatting.gofumpt": true, 83 | "formatting.local": "github.com/mrz1836/go-whatsonchain", 84 | "symbolMatcher": "fastfuzzy", 85 | "symbolStyle": "full", 86 | "ui.completion.completeFunctionCalls": true, 87 | "ui.completion.usePlaceholders": true, 88 | "ui.diagnostic.analyses": { 89 | "nilness": true, 90 | "shadow": true, 91 | "unusedparams": true, 92 | "unusedvariable": true, 93 | "unusedwrite": true 94 | }, 95 | "ui.diagnostic.staticcheck": true, 96 | "ui.navigation.importShortcut": "Definition", 97 | "ui.semanticTokens": true 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /.github/actions/extract-module-dir/action.yml: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------------ 2 | # Extract Go Module Directory Action (GoFortress) 3 | # 4 | # Purpose: Extract the Go module directory from the GO_SUM_FILE path and export 5 | # it as an environment variable for subsequent steps to use when running Go/magex 6 | # commands from the correct directory. 7 | # 8 | # Features: 9 | # - Derives module directory from go.sum file path 10 | # - Handles both subdirectory and root-level go.mod files 11 | # - Exports GO_MODULE_DIR environment variable 12 | # - Provides output for conditional logic 13 | # 14 | # Usage: 15 | # - uses: ./.github/actions/extract-module-dir 16 | # with: 17 | # go-sum-file: ${{ inputs.go-sum-file }} 18 | # 19 | # Maintainer: @mrz1836 20 | # 21 | # ------------------------------------------------------------------------------------ 22 | 23 | name: "Extract Go Module Directory" 24 | description: "Extract the Go module directory from the GO_SUM_FILE path for running Go/magex commands" 25 | 26 | inputs: 27 | go-sum-file: 28 | description: "Path to go.sum file for extracting module directory" 29 | required: true 30 | 31 | outputs: 32 | module-dir: 33 | description: "Go module directory path (empty if root directory)" 34 | value: ${{ steps.extract.outputs.module-dir }} 35 | is-root: 36 | description: "Whether the module is in the root directory (true/false)" 37 | value: ${{ steps.extract.outputs.is-root }} 38 | 39 | runs: 40 | using: "composite" 41 | steps: 42 | # -------------------------------------------------------------------- 43 | # Extract Go module directory from GO_SUM_FILE path 44 | # -------------------------------------------------------------------- 45 | - name: 🔧 Extract Go module directory 46 | id: extract 47 | shell: bash 48 | run: | 49 | GO_SUM_FILE="${{ inputs.go-sum-file }}" 50 | GO_MODULE_DIR=$(dirname "$GO_SUM_FILE") 51 | 52 | # Handle root directory case (dirname returns ".") 53 | if [ "$GO_MODULE_DIR" = "." ]; then 54 | GO_MODULE_DIR="" 55 | IS_ROOT="true" 56 | echo "📁 Go module directory: repository root" 57 | else 58 | IS_ROOT="false" 59 | echo "📁 Go module directory: $GO_MODULE_DIR" 60 | fi 61 | 62 | # Export for subsequent steps in the same job 63 | echo "GO_MODULE_DIR=$GO_MODULE_DIR" >> $GITHUB_ENV 64 | 65 | # Set outputs for use in other jobs or conditional logic 66 | echo "module-dir=$GO_MODULE_DIR" >> $GITHUB_OUTPUT 67 | echo "is-root=$IS_ROOT" >> $GITHUB_OUTPUT 68 | 69 | # Validate that the derived directory structure makes sense 70 | if [ -n "$GO_MODULE_DIR" ]; then 71 | GO_MOD_PATH="$GO_MODULE_DIR/go.mod" 72 | if [ -f "$GO_MOD_PATH" ]; then 73 | echo "✅ Verified: go.mod exists at $GO_MOD_PATH" 74 | else 75 | echo "⚠️ Warning: go.mod not found at $GO_MOD_PATH" 76 | fi 77 | 78 | if [ -f "$GO_SUM_FILE" ]; then 79 | echo "✅ Verified: go.sum exists at $GO_SUM_FILE" 80 | else 81 | echo "⚠️ Warning: go.sum not found at $GO_SUM_FILE" 82 | fi 83 | else 84 | echo "📋 Root directory mode - looking for go.mod and go.sum in repository root" 85 | fi 86 | -------------------------------------------------------------------------------- /.github/AGENTS.md: -------------------------------------------------------------------------------- 1 | # AGENTS.md 2 | 3 | ## 🎯 Purpose & Scope 4 | 5 | This file defines the **baseline standards, workflows, and structure** for *all contributors and AI agents* operating within this repository. It serves as the root authority for engineering conduct, coding conventions, and collaborative norms. 6 | 7 | It is designed to help AI assistants (e.g., Codex, Claude, Gemini) and human developers alike understand our practices, contribute clean and idiomatic code, and navigate the codebase confidently and effectively. 8 | 9 | > Whether reading, writing, testing, or committing code, **you must adhere to the rules in this document.** 10 | 11 | Additional `AGENTS.md` files **may exist in subdirectories** to provide more contextual or specialized guidance. These local agent files are allowed to **extend or override** the root rules to fit the needs of specific packages, services, or engineering domains—while still respecting the spirit of consistency and quality defined here. 12 | 13 |

14 | 15 | ## 📚 Technical Conventions 16 | 17 | Our technical standards are organized into focused, portable documents in the `.github/tech-conventions/` directory: 18 | 19 | ### Core Development 20 | * **[Go Essentials](tech-conventions/go-essentials.md)** - Context-first design, interfaces, goroutines, error handling 21 | * **[Testing Standards](tech-conventions/testing-standards.md)** - Unit tests, coverage requirements, best practices 22 | * **[Commenting & Documentation](tech-conventions/commenting-documentation.md)** - Code comments, package docs, markdown 23 | 24 | ### Version Control & Collaboration 25 | * **[Commit & Branch Conventions](tech-conventions/commit-branch-conventions.md)** - Git workflow standards 26 | * **[Pull Request Guidelines](tech-conventions/pull-request-guidelines.md)** - PR structure and review process 27 | * **[Release Workflow & Versioning](tech-conventions/release-versioning.md)** - Semantic versioning and releases 28 | 29 | ### Project Management & Infrastructure 30 | * **[Labeling Conventions](tech-conventions/labeling-conventions.md)** - GitHub label system 31 | * **[Dependency Management](tech-conventions/dependency-management.md)** - Go modules and security 32 | * **[Security Practices](tech-conventions/security-practices.md)** - Vulnerability reporting and secure coding 33 | * **[GitHub Workflows Development](tech-conventions/github-workflows.md)** - Actions workflow best practices 34 | 35 | > 💡 **Start with [tech-conventions/README.md](tech-conventions/README.md)** for a complete index with descriptions. 36 | 37 |

38 | 39 | ## 📁 Directory Structure 40 | 41 | | Directory | Description | 42 | |-----------------------------|---------------------------------------------------------| 43 | | `.github/` | Issue templates, workflows, and community documentation | 44 | | `.github/actions/` | GitHub composite actions for CI/CD and automation | 45 | | `.github/ISSUE_TEMPLATE/` | Issue and pull request templates | 46 | | `.github/tech-conventions/` | Technical conventions and standards for development | 47 | | `.github/workflows/` | GitHub Actions workflows for CI/CD | 48 | | `.vscode/` | VS Code settings and extensions for development | 49 | | `.` (root) | Source files and tests for the local package | 50 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # ──────────────────────────────────────────────────────────────── 2 | # Dependabot Configuration 3 | # 4 | # Purpose: 5 | # • Keep Go modules, GitHub Actions and DevContainer images/features 6 | # base images up‑to‑date with zero‑day security patches and semantic‑version 7 | # upgrades. 8 | # • Reduce attack surface by limiting outdated dependencies. 9 | # • Minimise PR noise via smart grouping and sane pull‑request limits. 10 | # 11 | # References: 12 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates 13 | # https://docs.github.com/en/code-security/dependabot/configuration-options-for-dependency-updates 14 | # 15 | # Security Hardened Defaults: 16 | # • Weekly cadence (Monday 09:00 America/New_York) – align with CVE dump cycle. 17 | # • Direct dependencies only – prevents unsolicited transitive churn. 18 | # • PRs labeled, assigned, and target the protected "master" branch. 19 | # • PR titles prefixed with chore(scope): – conventional commits. 20 | # • Force‑push and delete‑branch disabled via branch‑protection rules. 21 | # • PR limit = 10 to avoid queue flooding. 22 | # • All dependency PRs require passing CI + CODEOWNERS review. 23 | # ──────────────────────────────────────────────────────────────── 24 | 25 | version: 2 26 | 27 | updates: 28 | # ────────────────────────────────────────────────────────────── 29 | # 1. Go Modules (go.mod / go.sum) 30 | # ────────────────────────────────────────────────────────────── 31 | - package-ecosystem: "gomod" 32 | directory: "/" 33 | target-branch: "master" 34 | schedule: 35 | interval: "weekly" 36 | day: "monday" 37 | time: "09:00" 38 | timezone: "America/New_York" 39 | allow: 40 | - dependency-type: "direct" 41 | groups: 42 | security-deps: 43 | patterns: 44 | - "*crypto*" 45 | - "*security*" 46 | - "*auth*" 47 | - "*jwt*" 48 | - "*oauth*" 49 | update-types: ["minor", "patch"] 50 | open-pull-requests-limit: 10 51 | assignees: ["mrz1836"] 52 | labels: ["chore", "dependencies", "gomod"] 53 | commit-message: 54 | prefix: "chore" 55 | include: "scope" 56 | 57 | # ────────────────────────────────────────────────────────────── 58 | # 2. GitHub Actions Workflows 59 | # ────────────────────────────────────────────────────────────── 60 | - package-ecosystem: "github-actions" 61 | directory: "/" 62 | target-branch: "master" 63 | schedule: 64 | interval: "weekly" 65 | day: "monday" 66 | time: "09:15" 67 | timezone: "America/New_York" 68 | allow: 69 | - dependency-type: "direct" 70 | groups: 71 | ghactions-all: 72 | patterns: ["*"] 73 | open-pull-requests-limit: 10 74 | assignees: ["mrz1836"] 75 | labels: ["chore", "dependencies", "github-actions"] 76 | commit-message: 77 | prefix: "chore" 78 | include: "scope" 79 | 80 | # ────────────────────────────────────────────────────────────── 81 | # 3. DevContainer (devcontainer.json : base image + features) 82 | # ────────────────────────────────────────────────────────────── 83 | - package-ecosystem: "devcontainers" 84 | directory: "/" 85 | target-branch: "master" 86 | schedule: 87 | interval: "weekly" 88 | day: "monday" 89 | time: "09:30" 90 | timezone: "America/New_York" 91 | allow: 92 | - dependency-type: "direct" 93 | groups: 94 | devcontainer-all: 95 | patterns: ["*"] 96 | open-pull-requests-limit: 5 97 | assignees: ["mrz1836"] 98 | labels: ["chore", "dependencies", "devcontainer"] 99 | commit-message: 100 | prefix: "chore" 101 | include: "scope" 102 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that GitHub does not certify. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecard supply-chain security 6 | on: 7 | # For Branch-Protection check. Only the default branch is supported. See 8 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 9 | branch_protection_rule: 10 | # To guarantee Maintained check is occasionally updated. See 11 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 12 | schedule: 13 | - cron: "0 8 * * 1" # Every Monday at 08:00 UTC 14 | push: 15 | branches: ["master", "main"] 16 | 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 19 | cancel-in-progress: true 20 | 21 | # Security: Restrictive default permissions with job-level overrides for least privilege access 22 | permissions: 23 | contents: read 24 | 25 | jobs: 26 | analysis: 27 | name: Scorecard analysis 28 | runs-on: ubuntu-latest 29 | # `publish_results: true` only works when run from the default branch. conditional can be removed if disabled. 30 | if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request' 31 | permissions: 32 | # Needed to upload the results to code-scanning dashboard. 33 | security-events: write 34 | # Needed to publish results and get a badge (see publish_results below). 35 | id-token: write 36 | # Uncomment the permissions below if installing in a private repository. 37 | # contents: read 38 | # actions: read 39 | 40 | steps: 41 | - name: "Checkout code" 42 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 43 | with: 44 | persist-credentials: false 45 | 46 | - name: "Run analysis" 47 | uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 48 | with: 49 | results_file: results.sarif 50 | results_format: sarif 51 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 52 | # - you want to enable the Branch-Protection check on a *public* repository, or 53 | # - you are installing Scorecard on a *private* repository 54 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. 55 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 56 | 57 | # Public repositories: 58 | # - Publish results to OpenSSF REST API for easy access by consumers 59 | # - Allows the repository to include the Scorecard badge. 60 | # - See https://github.com/ossf/scorecard-action#publishing-results. 61 | # For private repositories: 62 | # - `publish_results` will always be set to `false`, regardless 63 | # of the value entered here. 64 | publish_results: true 65 | 66 | # (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore 67 | # file_mode: git 68 | 69 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 70 | # format to the repository Actions tab. 71 | - name: "Upload artifact" 72 | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 73 | with: 74 | name: SARIF file 75 | path: results.sarif 76 | retention-days: 5 77 | 78 | # Upload the results to GitHub's code scanning dashboard (optional). 79 | # Commenting out will disable the upload of results to your repo's Code Scanning dashboard 80 | - name: "Upload to code-scanning" 81 | uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 82 | with: 83 | sarif_file: results.sarif 84 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | - name: "automated-sync" 2 | description: "Automated sync PR, e.g. from a fork or external repo" 3 | color: 006b75 4 | - name: "automerge" 5 | description: "Label to automatically merge pull requests that meet all required conditions" 6 | color: FEF2C0 7 | - name: "automerge-enabled" 8 | description: "Detected automerge PR and enabled automerge" 9 | color: 006b75 10 | - name: "bug-P1" 11 | description: "Highest rated bug or issue, affects all" 12 | color: b23128 13 | - name: "bug-P2" 14 | description: "Medium rated bug, affects a few" 15 | color: de3d32 16 | - name: "bug-P3" 17 | description: "Lowest rated bug, affects nearly none or low-impact" 18 | color: f44336 19 | - name: "chore" 20 | description: "Simple dependency updates or version bumps" 21 | color: 006b75 22 | - name: "coverage-override" 23 | description: "Override coverage requirements for this PR" 24 | color: ff8c00 25 | - name: "dependencies" 26 | description: "Dependency updates, version bumps, etc." 27 | color: 006b75 28 | - name: "dependabot" 29 | description: "PR or issue created by or related to Dependabot" 30 | color: 1a70c7 31 | - name: "devcontainer" 32 | description: "Used for referencing DevContainers" 33 | color: 006b75 34 | - name: "docker" 35 | description: "Used for referencing Docker related issues" 36 | color: 006b75 37 | - name: "documentation" 38 | description: "Improvements or additions to documentation" 39 | color: 0075ca 40 | - name: "epic" 41 | description: "Large feature or initiative spanning multiple tasks" 42 | color: 002f6c 43 | - name: "feature" 44 | description: "Any new significant addition" 45 | color: 0e8a16 46 | - name: "fork-pr" 47 | description: "PR originated from a forked repository" 48 | color: 5319e7 49 | - name: "github-actions" 50 | description: "Used for referencing GitHub Actions" 51 | color: 006b75 52 | - name: "gomod" 53 | description: "Used for referencing Go Modules" 54 | color: 006b75 55 | - name: "hot-fix" 56 | description: "Urgent or important fix/patch" 57 | color: b60205 58 | - name: "idea" 59 | description: "Any idea, suggestion" 60 | color: cccccc 61 | - name: "npm" 62 | description: "Used for referencing npm packages and dependencies" 63 | color: 006b75 64 | - name: "performance" 65 | description: "Performance improvements or optimizations" 66 | color: 8bc34a 67 | - name: "prototype" 68 | description: "Experimental - can break!" 69 | color: d4c5f9 70 | - name: "question" 71 | description: "Any question or concern" 72 | color: cc317c 73 | - name: "refactor" 74 | description: "Any significant refactoring" 75 | color: FFA500 76 | - name: "requires-manual-review" 77 | description: "PR or issue requires manual review by a maintainer or security team" 78 | color: ffd700 79 | - name: "security" 80 | description: "Security-related issue, vulnerability, or fix" 81 | color: d73a4a 82 | - name: "size/L" 83 | description: "Large change (201–500 lines)" 84 | color: 01579b 85 | - name: "size/M" 86 | description: "Medium change (51–200 lines)" 87 | color: 0288d1 88 | - name: "size/S" 89 | description: "Small change (11–50 lines)" 90 | color: 4fc3f7 91 | - name: "size/XL" 92 | description: "Very large change (>500 lines)" 93 | color: 002f6c 94 | - name: "size/XS" 95 | description: "Very small change (≤10 lines)" 96 | color: b3e5fc 97 | - name: "stale" 98 | description: "Old, unused, stale" 99 | color: c2e0c6 100 | - name: "task" 101 | description: "Actionable task or work item" 102 | color: 0288d1 103 | - name: "test" 104 | description: "Unit tests, mocking, integration testing" 105 | color: c2e0c6 106 | - name: "tested" 107 | description: "Successfully tested and ready for review" 108 | color: 4fc3f7 109 | - name: "ui-ux" 110 | description: "Anything GUI related" 111 | color: fbca04 112 | - name: "update" 113 | description: "General updates" 114 | color: 006b75 115 | - name: "work-in-progress" 116 | description: "Used for denoting a WIP, stops auto-merge" 117 | color: FBCA04 118 | -------------------------------------------------------------------------------- /bsv_specific_test.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | // mockHTTPOpReturnValid for mocking valid OP_RETURN requests 13 | type mockHTTPOpReturnValid struct{} 14 | 15 | // Do is a mock http request 16 | func (m *mockHTTPOpReturnValid) Do(req *http.Request) (*http.Response, error) { 17 | resp := new(http.Response) 18 | resp.StatusCode = http.StatusBadRequest 19 | 20 | // No req found 21 | if req == nil { 22 | return resp, ErrMissingRequest 23 | } 24 | 25 | // Valid OP_RETURN endpoint 26 | if strings.Contains(req.URL.String(), "/bsv/") && strings.Contains(req.URL.String(), "/opreturn") { 27 | resp.StatusCode = http.StatusOK 28 | resp.Body = io.NopCloser(bytes.NewBufferString(`48656c6c6f20576f726c64`)) 29 | } 30 | 31 | return resp, nil 32 | } 33 | 34 | // mockHTTPOpReturnNotFound for mocking not found responses 35 | type mockHTTPOpReturnNotFound struct{} 36 | 37 | // Do is a mock http request 38 | func (m *mockHTTPOpReturnNotFound) Do(req *http.Request) (*http.Response, error) { 39 | resp := new(http.Response) 40 | resp.StatusCode = http.StatusNotFound 41 | 42 | // No req found 43 | if req == nil { 44 | return resp, ErrMissingRequest 45 | } 46 | 47 | resp.Body = io.NopCloser(bytes.NewBufferString(`{"error":"Transaction not found"}`)) 48 | return resp, ErrTransactionNotFound 49 | } 50 | 51 | // mockHTTPOpReturnEmpty for mocking empty responses 52 | type mockHTTPOpReturnEmpty struct{} 53 | 54 | // Do is a mock http request 55 | func (m *mockHTTPOpReturnEmpty) Do(req *http.Request) (*http.Response, error) { 56 | resp := new(http.Response) 57 | resp.StatusCode = http.StatusOK 58 | 59 | // No req found 60 | if req == nil { 61 | return resp, ErrMissingRequest 62 | } 63 | 64 | resp.Body = io.NopCloser(bytes.NewBufferString(``)) 65 | return resp, nil 66 | } 67 | 68 | // TestClient_GetOpReturnData tests the GetOpReturnData method 69 | func TestClient_GetOpReturnData(t *testing.T) { 70 | t.Parallel() 71 | 72 | t.Run("successful OP_RETURN data retrieval", func(t *testing.T) { 73 | client := newMockClientBSV(&mockHTTPOpReturnValid{}) 74 | 75 | data, err := client.GetOpReturnData(context.Background(), "46c5495468b68248b69e55aa76a6b9ca1cb343bee9477c9c121358380e421ff3") 76 | if err != nil { 77 | t.Fatalf("expected no error, got %v", err) 78 | } 79 | 80 | if data != "48656c6c6f20576f726c64" { 81 | t.Errorf("expected '48656c6c6f20576f726c64', got '%s'", data) 82 | } 83 | }) 84 | 85 | t.Run("error response handling", func(t *testing.T) { 86 | client := newMockClientBSV(&mockHTTPOpReturnNotFound{}) 87 | 88 | _, err := client.GetOpReturnData(context.Background(), "nonexistent") 89 | if err == nil { 90 | t.Fatal("expected an error, got nil") 91 | } 92 | }) 93 | 94 | t.Run("empty response handling", func(t *testing.T) { 95 | client := newMockClientBSV(&mockHTTPOpReturnEmpty{}) 96 | 97 | data, err := client.GetOpReturnData(context.Background(), "empty") 98 | if err != nil { 99 | t.Fatalf("expected no error, got %v", err) 100 | } 101 | 102 | if data != "" { 103 | t.Errorf("expected empty string, got '%s'", data) 104 | } 105 | }) 106 | 107 | t.Run("chain restriction - BTC client", func(t *testing.T) { 108 | btcClient := newMockClientBTC(&mockHTTPOpReturnValid{}) 109 | 110 | _, err := btcClient.GetOpReturnData(context.Background(), "test") 111 | if err == nil { 112 | t.Fatal("expected an error for BTC chain, got nil") 113 | } 114 | 115 | expectedError := "operation is only available for BSV chain" 116 | if err.Error() != expectedError { 117 | t.Errorf("expected error '%s', got '%s'", expectedError, err.Error()) 118 | } 119 | }) 120 | 121 | t.Run("chain restriction - BSV client", func(t *testing.T) { 122 | bsvClient := newMockClientBSV(&mockHTTPOpReturnValid{}) 123 | 124 | data, err := bsvClient.GetOpReturnData(context.Background(), "test") 125 | if err != nil { 126 | t.Fatalf("BSV client should allow GetOpReturnData, got error: %v", err) 127 | } 128 | 129 | if data != "" { 130 | // Should get the mock response 131 | t.Logf("got data: %s", data) 132 | } 133 | }) 134 | } 135 | -------------------------------------------------------------------------------- /.github/tech-conventions/pull-request-guidelines.md: -------------------------------------------------------------------------------- 1 | # Pull Request Guidelines 2 | 3 | > Pull Requests—whether authored by humans or AI agents—must follow a consistent structure to ensure clarity, accountability, and ease of review. 4 | 5 |

6 | 7 | ## 🔖 Title Format 8 | 9 | ``` 10 | [Subsystem] Imperative and concise summary of change 11 | ``` 12 | 13 | Examples: 14 | 15 | * `[API] Add pagination to client search endpoint` 16 | * `[DB] Migrate legacy rate table schema` 17 | * `[CI] Remove deprecated GitHub Action for testing` 18 | 19 | > Use the imperative mood ("Add", "Fix", "Update") to match the style of commit messages and changelogs. 20 | 21 |

22 | 23 | ## 📝 Pull Request Description 24 | 25 | Every PR must include the following **four** sections in the description: 26 | 27 | ### 1. **What Changed** 28 | 29 | > A clear, bullet‑pointed or paragraph‑level summary of the technical changes. 30 | 31 | ### 2. **Why It Was Necessary** 32 | 33 | > Context or motivation behind the change. Reference related issues, discussions, or bugs if applicable. 34 | 35 | ### 3. **Testing Performed** 36 | 37 | > Document: 38 | > 39 | > * Test suites run (e.g., `TestCreateOriginationAccount`) 40 | > * Edge cases covered 41 | > * Manual steps that were taken (if any) 42 | 43 | ### 4. **Impact / Risk** 44 | 45 | > Call out: 46 | > 47 | > * Breaking changes 48 | > * Regression risk 49 | > * Performance implications 50 | > * Changes in developer experience (e.g., local dev setup, CI time) 51 | 52 |

53 | 54 | ## 💡 Additional PR Guidelines 55 | 56 | * Link related issues with keywords like `Closes #123` or `Fixes #456` if there is a known issue. 57 | * Keep PRs focused and minimal. Prefer multiple small PRs over large ones when possible. 58 | * Use draft PRs early for feedback on in progress work. 59 | * Releases are deployed using **goreleaser**. 60 | * Rules for the release build are located in `.goreleaser.yml` and executed via `.github/workflows/release.yml`. 61 | 62 |

63 | 64 | ## 📋 PR Template Example 65 | 66 | ```markdown 67 | ## What Changed 68 | 69 | * Added pagination support to the `/api/v1/users` endpoint 70 | * Implemented cursor-based pagination using user IDs 71 | * Added `limit` and `cursor` query parameters 72 | * Updated OpenAPI specification 73 | 74 | ## Why It Was Necessary 75 | 76 | Users were experiencing timeouts when fetching large user lists. This change implements 77 | pagination to improve performance and reduce memory usage. 78 | 79 | Closes #123 80 | 81 | ## Testing Performed 82 | 83 | * Added unit tests in `TestUsersPagination` 84 | * Tested edge cases: 85 | * Empty result sets 86 | * Invalid cursor values 87 | * Limits exceeding maximum 88 | * Manual testing with 10k+ user dataset 89 | * Verified backwards compatibility with existing clients 90 | 91 | ## Impact / Risk 92 | 93 | * **Breaking Change**: None - pagination is optional 94 | * **Performance**: 80% reduction in response time for large datasets 95 | * **Risk**: Low - feature is behind query parameters 96 | * **Migration**: None required 97 | ``` 98 | 99 |

100 | 101 | ## 🔍 PR Review Checklist 102 | 103 | Before requesting review, ensure: 104 | 105 | - [ ] Code follows project conventions (see go-essentials.md) 106 | - [ ] Tests are included and passing 107 | - [ ] Documentation is updated (if applicable) 108 | - [ ] Commits are clean and follow conventions 109 | - [ ] PR description includes all four required sections 110 | - [ ] No sensitive data or secrets are exposed 111 | - [ ] CI checks are passing 112 | 113 |

114 | 115 | ## 🤝 Review Etiquette 116 | 117 | ### For Authors: 118 | * **Respond to all comments** — Even if just to acknowledge 119 | * **Push fixes as new commits** — Don't force-push during review 120 | * **Be open to feedback** — Reviews improve code quality 121 | * **Provide context** — Help reviewers understand your decisions 122 | 123 | ### For Reviewers: 124 | * **Be constructive** — Suggest improvements, don't just criticize 125 | * **Review promptly** — Aim for same-day initial review 126 | * **Focus on important issues** — Nitpicks can be marked as optional 127 | * **Approve explicitly** — Use GitHub's review approval feature 128 | 129 |

130 | 131 | ## 🚀 Merging 132 | 133 | * **Squash and merge** for feature branches with messy history 134 | * **Rebase and merge** for clean, logical commit sequences 135 | * **Never force-push** to main/master branches 136 | * **Delete branches** after merging to keep the repository clean 137 | 138 | > The merge strategy may vary by project. Check with maintainers if unsure. 139 | -------------------------------------------------------------------------------- /.github/tech-conventions/release-versioning.md: -------------------------------------------------------------------------------- 1 | # Release Workflow & Versioning 2 | 3 | > Structured releases ensure predictable deployments and clear communication of changes. 4 | 5 |

6 | 7 | ## 🚀 Semantic Versioning 8 | 9 | We follow **Semantic Versioning (✧ SemVer)**: 10 | `MAJOR.MINOR.PATCH` → `1.2.3` 11 | 12 | | Segment | Bumps When... | Examples | 13 | |-----------|---------------------------------------|-----------------| 14 | | **MAJOR** | Breaking API change | `1.0.0 → 2.0.0` | 15 | | **MINOR** | Back‑compatible feature / enhancement | `1.2.0 → 1.3.0` | 16 | | **PATCH** | Back‑compatible bug fix / docs | `1.2.3 → 1.2.4` | 17 | 18 |

19 | 20 | ## 📦 Release Tooling 21 | 22 | * Releases are driven by **[goreleaser]** and configured in `.goreleaser.yml`. 23 | * Install locally with Homebrew (Mac): 24 | ```bash 25 | brew install goreleaser 26 | ``` 27 | 28 |

29 | 30 | ## 🔄 Release Workflow 31 | 32 | | Step | Command | Purpose | 33 | |------|-------------------------------------------|----------------------------------------------------------------------------------------------------| 34 | | 1 | `magex release:snapshot` | Build & upload a **snapshot** (pre‑release) for quick CI validation. | 35 | | 2 | `magex version:bump push=true bump=patch` | Create and push a signed Git tag. Triggers GitHub Actions to package the release | 36 | | 3 | GitHub Actions | CI runs `goreleaser release` on the tag; artifacts and changelog are published to GitHub Releases. | 37 | 38 | > **Note for AI Agents:** Do not create or push tags automatically. Only the repository [codeowners](../CODEOWNERS) are authorized to tag and publish official releases. 39 | 40 | [goreleaser]: https://github.com/goreleaser/goreleaser 41 | 42 |

43 | 44 | ## 📝 Changelog Management 45 | 46 | ### Automatic Generation 47 | GoReleaser automatically generates changelogs from commit messages: 48 | * Groups commits by type (`feat`, `fix`, `docs`, etc.) 49 | * Excludes certain commit types (configured in `.goreleaser.yml`) 50 | * Links to PRs and issues mentioned in commits 51 | 52 | ### Manual Additions 53 | For significant releases, you may want to add a manual summary: 54 | 1. Create a draft release on GitHub 55 | 2. Edit the auto-generated changelog 56 | 3. Add a "Highlights" section at the top 57 | 4. Call out breaking changes prominently 58 | 59 |

60 | 61 | ## 🏷️ Version Tags 62 | 63 | ### Tag Format 64 | * Release tags: `v1.2.3` (always prefix with `v`) 65 | * Pre-release tags: `v1.2.3-rc.1`, `v1.2.3-beta.2` 66 | * Development snapshots: Generated automatically, not tagged 67 | 68 |

69 | 70 | ## 📦 Release Artifacts 71 | 72 | GoReleaser produces: 73 | * **Binaries** for multiple platforms (Linux, macOS, Windows) 74 | * **Docker images** (if configured) 75 | * **Checksums** for verification 76 | * **Release notes** from commits 77 | * **Source archives** (tar.gz, zip) 78 | 79 | All artifacts are automatically uploaded to GitHub Releases. 80 | 81 |

82 | 83 | ## 🔍 Pre-Release Checklist 84 | 85 | Before tagging a release: 86 | 87 | - [ ] All linters passing (`magex lint`) 88 | - [ ] All tests passing (`magex test`) 89 | - [ ] No security vulnerabilities (`magex deps:audit`) 90 | - [ ] Documentation updated 91 | - [ ] Version bumped if needed 92 | - [ ] PR merged to main branch 93 | 94 |

95 | 96 | ## 🚨 Hotfix Process 97 | 98 | For critical production fixes: 99 | 100 | 1. Create branch from the release tag: `git checkout -b hotfix/security-fix v1.2.3` 101 | 2. Apply minimal fix 102 | 3. Test thoroughly 103 | 4. Tag as patch: `v1.2.4` 104 | 5. Cherry-pick to main branch 105 | 6. Document in release notes 106 | 107 |

108 | 109 | ## 📊 Version History 110 | 111 | Check release history: 112 | ```bash 113 | # List all tags 114 | git tag -l 115 | 116 | # Show specific release info 117 | git show v1.2.3 118 | 119 | # Compare versions 120 | git log v1.2.2..v1.2.3 --oneline 121 | ``` 122 | 123 |

124 | 125 | ## 🤖 Automation 126 | 127 | The release process is largely automated via GitHub Actions: 128 | * **Trigger**: Push of a tag matching `v*` 129 | * **Workflow**: `.github/workflows/fortress-release.yml` 130 | * **Configuration**: `.goreleaser.yml` 131 | * **Permissions**: Requires `GITHUB_TOKEN` with release permissions 132 | 133 | > Manual intervention should rarely be needed. If issues arise, check the Actions tab. 134 | -------------------------------------------------------------------------------- /stats.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | // BlockStats represents block statistics 9 | type BlockStats struct { 10 | Height int64 `json:"height"` 11 | Hash string `json:"hash"` 12 | Version int `json:"version"` 13 | Size int `json:"size"` 14 | Weight int `json:"weight"` 15 | MerkleRoot string `json:"merkleroot"` 16 | Timestamp int64 `json:"timestamp"` 17 | MedianTime int64 `json:"mediantime"` 18 | Nonce int64 `json:"nonce"` 19 | Bits string `json:"bits"` 20 | Difficulty float64 `json:"difficulty"` 21 | ChainWork string `json:"chainwork"` 22 | TxCount int `json:"tx_count"` 23 | TotalSize int `json:"total_size"` 24 | TotalFees int64 `json:"total_fees"` 25 | SubsidyTotal int64 `json:"subsidy_total"` 26 | SubsidyAddress int64 `json:"subsidy_address"` 27 | SubsidyMiner int64 `json:"subsidy_miner"` 28 | MinerName string `json:"miner_name"` 29 | MinerAddress string `json:"miner_address"` 30 | FeeRateAvg float64 `json:"fee_rate_avg"` 31 | FeeRateMin float64 `json:"fee_rate_min"` 32 | FeeRateMax float64 `json:"fee_rate_max"` 33 | FeeRateMedian float64 `json:"fee_rate_median"` 34 | FeeRateStdDev float64 `json:"fee_rate_stddev"` 35 | InputCount int `json:"input_count"` 36 | OutputCount int `json:"output_count"` 37 | UTXOIncrease int `json:"utxo_increase"` 38 | UTXOSizeInc int `json:"utxo_size_inc"` 39 | } 40 | 41 | // MinerStats represents miner statistics 42 | type MinerStats struct { 43 | Name string `json:"name"` 44 | Address string `json:"address"` 45 | BlockCount int `json:"block_count"` 46 | Percentage float64 `json:"percentage"` 47 | } 48 | 49 | // MinerFeeStats represents miner fee statistics 50 | type MinerFeeStats struct { 51 | Timestamp int64 `json:"timestamp"` 52 | Name string `json:"name"` 53 | FeeRate float64 `json:"fee_rate"` 54 | } 55 | 56 | // MinerSummaryStats represents miner summary statistics 57 | type MinerSummaryStats struct { 58 | Days int `json:"days"` 59 | Miners []*MinerStats `json:"miners"` 60 | } 61 | 62 | // TagCount represents tag count statistics by height 63 | type TagCount struct { 64 | Height int64 `json:"height"` 65 | Hash string `json:"hash"` 66 | TagCounts map[string]int `json:"tag_counts"` 67 | } 68 | 69 | // GetBlockStats gets block statistics by height 70 | // 71 | // For more information: https://developers.whatsonchain.com/#block-stats 72 | func (c *Client) GetBlockStats(ctx context.Context, height int64) (*BlockStats, error) { 73 | url := c.buildURL("/block/height/%d/stats", height) 74 | return requestAndUnmarshal[BlockStats](ctx, c, url, http.MethodGet, nil, nil) 75 | } 76 | 77 | // GetBlockStatsByHash gets block statistics by hash 78 | // 79 | // For more information: https://developers.whatsonchain.com/#block-stats 80 | func (c *Client) GetBlockStatsByHash(ctx context.Context, hash string) (*BlockStats, error) { 81 | url := c.buildURL("/block/hash/%s/stats", hash) 82 | return requestAndUnmarshal[BlockStats](ctx, c, url, http.MethodGet, nil, nil) 83 | } 84 | 85 | // GetMinerBlocksStats gets miner blocks statistics 86 | // 87 | // For more information: https://developers.whatsonchain.com/#miner-stats 88 | func (c *Client) GetMinerBlocksStats(ctx context.Context, days int) ([]*MinerStats, error) { 89 | url := c.buildURL("/miner/blocks/stats?days=%d", days) 90 | return requestAndUnmarshalSlice[*MinerStats](ctx, c, url, http.MethodGet, nil, nil) 91 | } 92 | 93 | // GetMinerFeesStats gets miner fees statistics 94 | // 95 | // For more information: https://developers.whatsonchain.com/#miner-stats 96 | func (c *Client) GetMinerFeesStats(ctx context.Context, from, to int64) ([]*MinerFeeStats, error) { 97 | url := c.buildURL("/miner/fees?from=%d&to=%d", from, to) 98 | return requestAndUnmarshalSlice[*MinerFeeStats](ctx, c, url, http.MethodGet, nil, nil) 99 | } 100 | 101 | // GetMinerSummaryStats gets miner summary statistics 102 | // 103 | // For more information: https://developers.whatsonchain.com/#miner-stats 104 | func (c *Client) GetMinerSummaryStats(ctx context.Context, days int) (*MinerSummaryStats, error) { 105 | url := c.buildURL("/miner/summary/stats?days=%d", days) 106 | return requestAndUnmarshal[MinerSummaryStats](ctx, c, url, http.MethodGet, nil, nil) 107 | } 108 | 109 | // GetTagCountByHeight gets tag count statistics by height 110 | // 111 | // For more information: https://developers.whatsonchain.com/#tag-count-stats 112 | func (c *Client) GetTagCountByHeight(ctx context.Context, height int64) (*TagCount, error) { 113 | url := c.buildURL("/block/tagcount/height/%d/stats", height) 114 | return requestAndUnmarshal[TagCount](ctx, c, url, http.MethodGet, nil, nil) 115 | } 116 | -------------------------------------------------------------------------------- /search_test.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | // mockHTTPSearchValid for mocking requests 14 | type mockHTTPSearchValid struct{} 15 | 16 | // queryData is the query for searching 17 | type queryData struct { 18 | Query string `json:"query"` 19 | } 20 | 21 | // Do is a mock http request 22 | func (m *mockHTTPSearchValid) Do(req *http.Request) (*http.Response, error) { 23 | resp := new(http.Response) 24 | resp.StatusCode = http.StatusBadRequest 25 | 26 | // No req found 27 | if req == nil { 28 | return resp, ErrMissingRequest 29 | } 30 | 31 | decoder := json.NewDecoder(req.Body) 32 | var data queryData 33 | err := decoder.Decode(&data) 34 | if err != nil { 35 | return resp, err 36 | } 37 | 38 | // Valid (address) 39 | if strings.Contains(data.Query, "1GJ3x5bcEnKMnzNFPPELDfXUCwKEaLHM5H") { 40 | resp.StatusCode = http.StatusOK 41 | resp.Body = io.NopCloser(bytes.NewBufferString(`{"results":[{"type":"address","url":"https://whatsonchain.com/address/1GJ3x5bcEnKMnzNFPPELDfXUCwKEaLHM5H"}]}`)) 42 | } 43 | 44 | // Valid (tx) 45 | if strings.Contains(data.Query, "6a7c821fd13c5cec773f7e221479651804197866469e92a4d6d47e1fd34d090d") { 46 | resp.StatusCode = http.StatusOK 47 | resp.Body = io.NopCloser(bytes.NewBufferString(`{"results":[{"type":"tx","url":"https://whatsonchain.com/tx/6a7c821fd13c5cec773f7e221479651804197866469e92a4d6d47e1fd34d090d"}]}`)) 48 | } 49 | 50 | // Valid (block) 51 | if strings.Contains(data.Query, "000000000000000002080d0ad78d08691d956d08fb8556339b6dd84fbbfdf1bc") { 52 | resp.StatusCode = http.StatusOK 53 | resp.Body = io.NopCloser(bytes.NewBufferString(`{"results":[{"type":"block","url":"https://whatsonchain.com/block/000000000000000002080d0ad78d08691d956d08fb8556339b6dd84fbbfdf1bc"}]}`)) 54 | } 55 | 56 | // Valid (op_return) 57 | if strings.Contains(data.Query, "unknown") { 58 | resp.StatusCode = http.StatusOK 59 | resp.Body = io.NopCloser(bytes.NewBufferString(`{"results":[{"type":"op_return","url":"https://whatsonchain.com/opreturn-query?term=unknown\u0026size=10\u0026offset=0"}]}`)) 60 | } 61 | 62 | // Invalid 63 | if strings.Contains(data.Query, "error") { 64 | resp.Body = io.NopCloser(bytes.NewBufferString("")) 65 | return resp, ErrBadRequest 66 | } 67 | 68 | // Not found 69 | if strings.Contains(data.Query, "notFound") { 70 | resp.StatusCode = http.StatusNotFound 71 | resp.Body = io.NopCloser(bytes.NewBufferString("")) 72 | return resp, nil 73 | } 74 | 75 | // Default is valid 76 | return resp, nil 77 | } 78 | 79 | // TestClient_GetExplorerLinks tests the GetExplorerLinks() 80 | func TestClient_GetExplorerLinks(t *testing.T) { 81 | t.Parallel() 82 | 83 | // New mock client 84 | client := newMockClient(&mockHTTPSearchValid{}) 85 | ctx := context.Background() 86 | 87 | // Create the list of tests 88 | tests := []struct { 89 | input string 90 | typeName string 91 | url string 92 | expectedError bool 93 | statusCode int 94 | }{ 95 | {"1GJ3x5bcEnKMnzNFPPELDfXUCwKEaLHM5H", "address", "https://whatsonchain.com/address/1GJ3x5bcEnKMnzNFPPELDfXUCwKEaLHM5H", false, http.StatusOK}, 96 | {"6a7c821fd13c5cec773f7e221479651804197866469e92a4d6d47e1fd34d090d", "tx", "https://whatsonchain.com/tx/6a7c821fd13c5cec773f7e221479651804197866469e92a4d6d47e1fd34d090d", false, http.StatusOK}, 97 | {"000000000000000002080d0ad78d08691d956d08fb8556339b6dd84fbbfdf1bc", "block", "https://whatsonchain.com/block/000000000000000002080d0ad78d08691d956d08fb8556339b6dd84fbbfdf1bc", false, http.StatusOK}, 98 | {"unknown", "op_return", "https://whatsonchain.com/opreturn-query?term=unknown&size=10&offset=0", false, http.StatusOK}, 99 | {"error", "", "", true, http.StatusBadRequest}, 100 | {"notFound", "", "", true, http.StatusNotFound}, 101 | } 102 | 103 | // Test all 104 | for _, test := range tests { 105 | output, err := client.GetExplorerLinks(ctx, test.input) 106 | 107 | if err == nil && test.expectedError { 108 | t.Errorf("%s Failed: expected to throw an error, no error [%s] inputted", t.Name(), test.input) 109 | continue 110 | } 111 | 112 | if err != nil && !test.expectedError { 113 | t.Errorf("%s Failed: [%s] inputted, received: [%v] error [%s]", t.Name(), test.input, output, err.Error()) 114 | continue 115 | } 116 | 117 | if client.LastRequest().StatusCode != test.statusCode { 118 | t.Errorf("%s Expected status code to be %d, got %d, [%s] inputted", t.Name(), test.statusCode, client.LastRequest().StatusCode, test.input) 119 | continue 120 | } 121 | 122 | if !test.expectedError && err == nil && output.Results != nil { 123 | if output.Results[0].Type != test.typeName { 124 | t.Errorf("%s Failed: [%s] inputted and [%s] type expected, received: [%s]", t.Name(), test.input, test.typeName, output.Results[0].Type) 125 | } 126 | if output.Results[0].URL != test.url { 127 | t.Errorf("%s Failed: [%s] inputted and [%s] url expected, received: [%s]", t.Name(), test.input, test.url, output.Results[0].URL) 128 | } 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /.github/tech-conventions/commit-branch-conventions.md: -------------------------------------------------------------------------------- 1 | # Commit & Branch Naming Conventions 2 | 3 | > Clear history ⇒ easy maintenance. Follow these rules for every commit and branch. 4 | 5 |

6 | 7 | ## 📌 Commit Message Format 8 | 9 | ``` 10 | (): 11 | 12 | # optional, wrap at 72 chars 13 | ``` 14 | 15 | * **``** — `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, `build`, `ci` 16 | * **``** — Affected subsystem or package (e.g., `api`, `deps`). Omit if global. 17 | * **Short description** — ≤ 50 chars, imperative mood ("add pagination", "fix panic") 18 | * **Body** (optional) — What & why, links to issues (`Closes #123`), and breaking‑change note (`BREAKING CHANGE:`) 19 | 20 | **Examples** 21 | 22 | ``` 23 | feat(package): add new method called: Thing() 24 | fix(generator): handle malformed JSON input gracefully 25 | docs(README): improve installation instructions 26 | ``` 27 | 28 | > Commits that only tweak whitespace, comments, or docs inside a PR may be squashed; otherwise preserve granular commits. 29 | 30 |

31 | 32 | ## 📝 go-pre-commit System (Optional) 33 | 34 | To ensure consistent commit messages and code quality, we use the external **go-pre-commit** tool that checks formatting, linting, and other standards before allowing a commit. The system is configured via `.github/.env.base` and can be installed with: 35 | 36 | ```bash 37 | # Install the external tool 38 | go install github.com/mrz1836/go-pre-commit/cmd/go-pre-commit@latest 39 | 40 | # Install hooks in your repository 41 | go-pre-commit install 42 | ``` 43 | 44 | Run the pre-commit checks manually with: 45 | ```bash 46 | go-pre-commit run 47 | ``` 48 | 49 | > The go-pre-commit system provides 17x faster execution than traditional Python-based pre-commit hooks and automatically enforces all code quality standards. See **[Pre-commit Hooks](pre-commit.md)** for comprehensive documentation. 50 | 51 |

52 | 53 | ## 🌱 Branch Naming 54 | 55 | | Purpose | Prefix | Example | 56 | |--------------------|-------------|------------------------------------| 57 | | Bug Fix | `fix/` | `fix/code-off-by-one` | 58 | | Chore / Meta | `chore/` | `chore/upgrade-go-1.24` | 59 | | Documentation | `docs/` | `docs/agents-commenting-standards` | 60 | | Feature | `feat/` | `feat/pagination-api` | 61 | | Hotfix (prod) | `hotfix/` | `hotfix/rollback-broken-deploy` | 62 | | Prototype / Spike | `proto/` | `proto/iso3166-expansion` | 63 | | Refactor / Cleanup | `refactor/` | `refactor/remove-dead-code` | 64 | | Tests | `test/` | `test/generator-edge-cases` | 65 | 66 | * Use **kebab‑case** after the prefix. 67 | * Keep branch names concise yet descriptive. 68 | * PR titles should mirror the branch's purpose (see Pull Request Guidelines). 69 | 70 | > CI rely on these prefixes for auto labeling and workflow routing—stick to them. 71 | 72 |

73 | 74 | ## 🎯 Commit Best Practices 75 | 76 | ### Do: 77 | * **Write atomic commits** — Each commit should represent one logical change 78 | * **Use imperative mood** — "Add feature" not "Added feature" 79 | * **Reference issues** — Include issue numbers when applicable 80 | * **Explain why** — Use the commit body for context 81 | * **Sign your commits** — Use `git commit -s` when required 82 | 83 | ### Don't: 84 | * **Mix unrelated changes** — Keep commits focused 85 | * **Commit broken code** — Every commit should be buildable 86 | * **Use generic messages** — "Fix bug" or "Update code" are too vague 87 | * **Forget to proofread** — Check spelling and grammar 88 | 89 |

90 | 91 | ## 📊 Commit Message Examples 92 | 93 | ### Good Examples 94 | 95 | ``` 96 | feat(auth): add JWT token refresh endpoint 97 | 98 | Implements automatic token refresh to improve user experience. 99 | Tokens are refreshed 5 minutes before expiration. 100 | 101 | Closes #234 102 | ``` 103 | 104 | ``` 105 | fix(worker): prevent goroutine leak in batch processor 106 | 107 | The worker pool was not properly cleaning up goroutines when 108 | context was cancelled, leading to memory leaks in long-running 109 | services. 110 | 111 | Added proper context handling and wait group synchronization. 112 | ``` 113 | 114 | ``` 115 | refactor(cache): simplify TTL calculation logic 116 | 117 | Extracted TTL calculation into separate function to improve 118 | testability and reduce cognitive complexity. 119 | 120 | No functional changes. 121 | ``` 122 | 123 | ### Bad Examples 124 | 125 | ``` 126 | fixed stuff # Too vague 127 | WIP # Meaningless 128 | Update auth.go # What was updated and why? 129 | Bug fix # Which bug? What was the issue? 130 | ``` 131 | 132 |

133 | 134 | ## 🔄 Working with Git 135 | 136 | ### Useful Commands 137 | 138 | ```bash 139 | # Amend the last commit (before pushing) 140 | git commit --amend 141 | 142 | # Interactive rebase to clean up history (use with caution) 143 | git rebase -i HEAD~3 144 | 145 | # Show commit history with graph 146 | git log --oneline --graph --all 147 | 148 | # Cherry-pick specific commits 149 | git cherry-pick 150 | ``` 151 | 152 | ### Branch Management 153 | 154 | ```bash 155 | # Create and switch to new branch 156 | git checkout -b feat/new-feature 157 | 158 | # Delete local branch 159 | git branch -d feat/old-feature 160 | 161 | # Delete remote branch 162 | git push origin --delete feat/old-feature 163 | 164 | # Prune deleted remote branches 165 | git remote prune origin 166 | ``` 167 | 168 | > Always pull with rebase to keep history clean: `git pull --rebase` 169 | -------------------------------------------------------------------------------- /exchange_rates_test.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | // mockHTTPExchangeValid for mocking requests 13 | type mockHTTPExchangeValid struct{} 14 | 15 | // Do is a mock http request 16 | func (m *mockHTTPExchangeValid) Do(req *http.Request) (*http.Response, error) { 17 | resp := new(http.Response) 18 | resp.StatusCode = http.StatusBadRequest 19 | 20 | // No req found 21 | if req == nil { 22 | return resp, ErrMissingRequest 23 | } 24 | 25 | // Valid (historical exchange rate) 26 | if strings.Contains(req.URL.String(), "/exchangerate/historical") { 27 | resp.StatusCode = http.StatusOK 28 | resp.Body = io.NopCloser(bytes.NewBufferString(`[{"rate":38.542,"time":1660139745,"currency":"USD"},{"rate":39.123,"time":1660312545,"currency":"USD"}]`)) 29 | } 30 | // Valid (exchange rate) 31 | if strings.Contains(req.URL.String(), "/exchangerate") && !strings.Contains(req.URL.String(), "/historical") { 32 | resp.StatusCode = http.StatusOK 33 | resp.Body = io.NopCloser(bytes.NewBufferString(`{"rate":38.542,"time":1668439893,"currency":"USD"}`)) 34 | } 35 | 36 | // Default is valid 37 | return resp, nil 38 | } 39 | 40 | // mockHTTPExchangeInvalid for mocking requests 41 | type mockHTTPExchangeInvalid struct{} 42 | 43 | // Do is a mock http request 44 | func (m *mockHTTPExchangeInvalid) Do(req *http.Request) (*http.Response, error) { 45 | resp := new(http.Response) 46 | resp.StatusCode = http.StatusBadRequest 47 | 48 | // No req found 49 | if req == nil { 50 | return resp, ErrMissingRequest 51 | } 52 | 53 | // Invalid (exchange rate) 54 | if strings.Contains(req.URL.String(), "/exchangerate") { 55 | resp.Body = io.NopCloser(bytes.NewBufferString("")) 56 | return resp, ErrBadRequest 57 | } 58 | 59 | // Default is valid 60 | return resp, nil 61 | } 62 | 63 | // mockHTTPExchangeNotFound for mocking requests 64 | type mockHTTPExchangeNotFound struct{} 65 | 66 | // Do is a mock http request 67 | func (m *mockHTTPExchangeNotFound) Do(req *http.Request) (*http.Response, error) { 68 | resp := new(http.Response) 69 | resp.StatusCode = http.StatusNotFound 70 | 71 | // No req found 72 | if req == nil { 73 | return resp, ErrMissingRequest 74 | } 75 | 76 | // Invalid (exchange rate) 77 | if strings.Contains(req.URL.String(), "/exchangerate") { 78 | resp.Body = io.NopCloser(bytes.NewBufferString("")) 79 | return resp, nil 80 | } 81 | 82 | // Default is valid 83 | return resp, nil 84 | } 85 | 86 | // TestClient_GetExchangeRate tests the GetExchangeRate() 87 | func TestClient_GetExchangeRate(t *testing.T) { 88 | t.Parallel() 89 | 90 | // New mock client 91 | client := newMockClient(&mockHTTPExchangeValid{}) 92 | ctx := context.Background() 93 | 94 | // Test the valid response 95 | info, err := client.GetExchangeRate(ctx) 96 | if err != nil { 97 | t.Errorf("%s Failed: error [%s]", t.Name(), err.Error()) 98 | } else if info == nil { 99 | t.Errorf("%s Failed: info was nil", t.Name()) 100 | } else if info.Currency != "USD" { 101 | t.Errorf("%s Failed: currency was [%s] expected [%s]", t.Name(), info.Currency, "USD") 102 | } else if info.Rate != 38.542 { 103 | t.Errorf("%s Failed: currency was [%v] expected [%v]", t.Name(), info.Rate, 38.542) 104 | } 105 | 106 | // New invalid mock client 107 | client = newMockClient(&mockHTTPExchangeInvalid{}) 108 | 109 | // Test invalid response 110 | _, err = client.GetExchangeRate(ctx) 111 | if err == nil { 112 | t.Errorf("%s Failed: error should have occurred", t.Name()) 113 | } 114 | 115 | // New not found mock client 116 | client = newMockClient(&mockHTTPExchangeNotFound{}) 117 | 118 | // Test invalid response 119 | _, err = client.GetExchangeRate(ctx) 120 | if err == nil { 121 | t.Errorf("%s Failed: error should have occurred", t.Name()) 122 | } 123 | } 124 | 125 | // TestClient_GetHistoricalExchangeRate tests the GetHistoricalExchangeRate() 126 | func TestClient_GetHistoricalExchangeRate(t *testing.T) { 127 | t.Parallel() 128 | 129 | // New mock client 130 | client := newMockClient(&mockHTTPExchangeValid{}) 131 | ctx := context.Background() 132 | from := int64(1660139745) 133 | to := int64(1660312545) 134 | 135 | // Test the valid response 136 | rates, err := client.GetHistoricalExchangeRate(ctx, from, to) 137 | if err != nil { 138 | t.Errorf("%s Failed: error [%s]", t.Name(), err.Error()) 139 | } else if rates == nil { 140 | t.Errorf("%s Failed: rates was nil", t.Name()) 141 | } else if len(rates) != 2 { 142 | t.Errorf("%s Failed: expected 2 rates, got [%d]", t.Name(), len(rates)) 143 | } else if rates[0].Currency != "USD" { 144 | t.Errorf("%s Failed: first rate currency was [%s] expected [%s]", t.Name(), rates[0].Currency, "USD") 145 | } else if rates[0].Rate != 38.542 { 146 | t.Errorf("%s Failed: first rate was [%v] expected [%v]", t.Name(), rates[0].Rate, 38.542) 147 | } else if rates[0].Time != 1660139745 { 148 | t.Errorf("%s Failed: first rate time was [%d] expected [%d]", t.Name(), rates[0].Time, 1660139745) 149 | } else if rates[1].Rate != 39.123 { 150 | t.Errorf("%s Failed: second rate was [%v] expected [%v]", t.Name(), rates[1].Rate, 39.123) 151 | } 152 | 153 | // New invalid mock client 154 | client = newMockClient(&mockHTTPExchangeInvalid{}) 155 | 156 | // Test invalid response 157 | _, err = client.GetHistoricalExchangeRate(ctx, from, to) 158 | if err == nil { 159 | t.Errorf("%s Failed: error should have occurred", t.Name()) 160 | } 161 | 162 | // New not found mock client 163 | client = newMockClient(&mockHTTPExchangeNotFound{}) 164 | 165 | // Test invalid response 166 | _, err = client.GetHistoricalExchangeRate(ctx, from, to) 167 | if err == nil { 168 | t.Errorf("%s Failed: error should have occurred", t.Name()) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /chain_support_test.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | // TestClientWithChain tests the new constructor with chain parameter 9 | func TestClientWithChain(t *testing.T) { 10 | t.Parallel() 11 | 12 | tests := []struct { 13 | name string 14 | chain ChainType 15 | network NetworkType 16 | expectedChain ChainType 17 | expectedNetwork NetworkType 18 | }{ 19 | { 20 | name: "BSV main", 21 | chain: ChainBSV, 22 | network: NetworkMain, 23 | expectedChain: ChainBSV, 24 | expectedNetwork: NetworkMain, 25 | }, 26 | { 27 | name: "BTC main", 28 | chain: ChainBTC, 29 | network: NetworkMain, 30 | expectedChain: ChainBTC, 31 | expectedNetwork: NetworkMain, 32 | }, 33 | { 34 | name: "BSV test", 35 | chain: ChainBSV, 36 | network: NetworkTest, 37 | expectedChain: ChainBSV, 38 | expectedNetwork: NetworkTest, 39 | }, 40 | { 41 | name: "BTC test", 42 | chain: ChainBTC, 43 | network: NetworkTest, 44 | expectedChain: ChainBTC, 45 | expectedNetwork: NetworkTest, 46 | }, 47 | } 48 | 49 | for _, tt := range tests { 50 | t.Run(tt.name, func(t *testing.T) { 51 | t.Parallel() 52 | 53 | client, err := NewClient( 54 | context.Background(), 55 | WithChain(tt.chain), 56 | WithNetwork(tt.network), 57 | ) 58 | if err != nil { 59 | t.Fatal(err) 60 | } 61 | 62 | if client.Chain() != tt.expectedChain { 63 | t.Errorf("Chain() = %v, want %v", client.Chain(), tt.expectedChain) 64 | } 65 | 66 | if client.Network() != tt.expectedNetwork { 67 | t.Errorf("Network() = %v, want %v", client.Network(), tt.expectedNetwork) 68 | } 69 | }) 70 | } 71 | } 72 | 73 | // TestClient_DefaultChain tests that the new constructor defaults to BSV 74 | func TestClient_DefaultChain(t *testing.T) { 75 | t.Parallel() 76 | 77 | client, err := NewClient(context.Background()) 78 | if err != nil { 79 | t.Fatal(err) 80 | } 81 | 82 | // Should default to BSV for backward compatibility 83 | if client.Chain() != ChainBSV { 84 | t.Errorf("Chain() = %v, want %v", client.Chain(), ChainBSV) 85 | } 86 | 87 | if client.Network() != NetworkMain { 88 | t.Errorf("Network() = %v, want %v", client.Network(), NetworkMain) 89 | } 90 | } 91 | 92 | // TestChainSupport_BSV tests BSV-specific functionality 93 | func TestChainSupport_BSV(t *testing.T) { 94 | t.Parallel() 95 | 96 | client, err := NewClient( 97 | context.Background(), 98 | WithChain(ChainBSV), 99 | WithNetwork(NetworkMain), 100 | ) 101 | if err != nil { 102 | t.Fatal(err) 103 | } 104 | 105 | if client.Chain() != ChainBSV { 106 | t.Errorf("Expected BSV chain, got %v", client.Chain()) 107 | } 108 | 109 | // Verify that BSV client has BSV-specific interface methods 110 | // This is just a compile-time check that BSV methods exist 111 | var _ BSVService = client 112 | } 113 | 114 | // TestChainSupport_BTC tests BTC-specific functionality 115 | func TestChainSupport_BTC(t *testing.T) { 116 | t.Parallel() 117 | 118 | client, err := NewClient( 119 | context.Background(), 120 | WithChain(ChainBTC), 121 | WithNetwork(NetworkMain), 122 | ) 123 | if err != nil { 124 | t.Fatal(err) 125 | } 126 | 127 | if client.Chain() != ChainBTC { 128 | t.Errorf("Expected BTC chain, got %v", client.Chain()) 129 | } 130 | 131 | // Verify that BTC client has BTC-specific interface methods 132 | // This is just a compile-time check that BTC methods exist 133 | var _ BTCService = client 134 | } 135 | 136 | // TestChain_NetworkSupport tests all network combinations 137 | func TestChain_NetworkSupport(t *testing.T) { 138 | t.Parallel() 139 | 140 | networks := []NetworkType{NetworkMain, NetworkTest, NetworkStn} 141 | chains := []ChainType{ChainBSV, ChainBTC} 142 | 143 | for _, chain := range chains { 144 | for _, network := range networks { 145 | t.Run(string(chain)+"-"+string(network), func(t *testing.T) { 146 | client, err := NewClient( 147 | context.Background(), 148 | WithChain(chain), 149 | WithNetwork(network), 150 | ) 151 | if err != nil { 152 | t.Fatal(err) 153 | } 154 | 155 | if client.Chain() != chain { 156 | t.Errorf("Chain() = %v, want %v", client.Chain(), chain) 157 | } 158 | 159 | if client.Network() != network { 160 | t.Errorf("Network() = %v, want %v", client.Network(), network) 161 | } 162 | }) 163 | } 164 | } 165 | } 166 | 167 | // TestClient_ChainSetter tests the SetChain method 168 | func TestClient_ChainSetter(t *testing.T) { 169 | t.Parallel() 170 | 171 | client, err := NewClient( 172 | context.Background(), 173 | WithChain(ChainBSV), 174 | ) 175 | if err != nil { 176 | t.Fatal(err) 177 | } 178 | 179 | if client.Chain() != ChainBSV { 180 | t.Errorf("Initial Chain() = %v, want %v", client.Chain(), ChainBSV) 181 | } 182 | 183 | // Change to BTC 184 | client.SetChain(ChainBTC) 185 | 186 | if client.Chain() != ChainBTC { 187 | t.Errorf("After SetChain, Chain() = %v, want %v", client.Chain(), ChainBTC) 188 | } 189 | } 190 | 191 | // TestClient_NetworkSetter tests the SetNetwork method 192 | func TestClient_NetworkSetter(t *testing.T) { 193 | t.Parallel() 194 | 195 | client, err := NewClient( 196 | context.Background(), 197 | WithNetwork(NetworkMain), 198 | ) 199 | if err != nil { 200 | t.Fatal(err) 201 | } 202 | 203 | if client.Network() != NetworkMain { 204 | t.Errorf("Initial Network() = %v, want %v", client.Network(), NetworkMain) 205 | } 206 | 207 | // Change to test network 208 | client.SetNetwork(NetworkTest) 209 | 210 | if client.Network() != NetworkTest { 211 | t.Errorf("After SetNetwork, Network() = %v, want %v", client.Network(), NetworkTest) 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // TestErrorConstants tests all error constant definitions 12 | func TestErrorConstants(t *testing.T) { 13 | t.Parallel() 14 | 15 | testCases := []struct { 16 | name string 17 | err error 18 | expectedMsg string 19 | }{ 20 | {"ErrAddressNotFound", ErrAddressNotFound, "address not found"}, 21 | {"ErrBlockNotFound", ErrBlockNotFound, "block not found"}, 22 | {"ErrChainInfoNotFound", ErrChainInfoNotFound, "chain info not found"}, 23 | {"ErrChainTipsNotFound", ErrChainTipsNotFound, "chain tips not found"}, 24 | {"ErrPeerInfoNotFound", ErrPeerInfoNotFound, "peer info not found"}, 25 | {"ErrExchangeRateNotFound", ErrExchangeRateNotFound, "exchange rate not found"}, 26 | {"ErrMempoolInfoNotFound", ErrMempoolInfoNotFound, "mempool info not found"}, 27 | {"ErrHeadersNotFound", ErrHeadersNotFound, "headers not found"}, 28 | {"ErrScriptNotFound", ErrScriptNotFound, "script not found"}, 29 | {"ErrTransactionNotFound", ErrTransactionNotFound, "transaction not found"}, 30 | {"ErrMaxAddressesExceeded", ErrMaxAddressesExceeded, "max limit of addresses exceeded"}, 31 | {"ErrMaxScriptsExceeded", ErrMaxScriptsExceeded, "max limit of scripts exceeded"}, 32 | {"ErrBroadcastFailed", ErrBroadcastFailed, "error broadcasting transaction"}, 33 | {"ErrMaxTransactionsExceeded", ErrMaxTransactionsExceeded, "max transactions limit exceeded"}, 34 | {"ErrMaxPayloadSizeExceeded", ErrMaxPayloadSizeExceeded, "max overall payload size exceeded"}, 35 | {"ErrMaxTransactionSizeExceeded", ErrMaxTransactionSizeExceeded, "max transaction size exceeded"}, 36 | {"ErrMaxUTXOsExceeded", ErrMaxUTXOsExceeded, "max limit of UTXOs exceeded"}, 37 | {"ErrMaxRawTransactionsExceeded", ErrMaxRawTransactionsExceeded, "max limit of raw transactions exceeded"}, 38 | {"ErrMissingRequest", ErrMissingRequest, "missing request"}, 39 | {"ErrBadRequest", ErrBadRequest, "bad request"}, 40 | {"ErrBSVChainRequired", ErrBSVChainRequired, "operation is only available for BSV chain"}, 41 | {"ErrBTCChainRequired", ErrBTCChainRequired, "operation is only available for BTC chain"}, 42 | {"ErrTokenNotFound", ErrTokenNotFound, "token not found"}, 43 | } 44 | 45 | for _, tc := range testCases { 46 | t.Run(tc.name, func(t *testing.T) { 47 | // Test that error implements error interface 48 | assert.Implements(t, (*error)(nil), tc.err) 49 | 50 | // Test error message 51 | assert.Equal(t, tc.expectedMsg, tc.err.Error()) 52 | 53 | // Test that error is not nil 54 | assert.Error(t, tc.err) 55 | }) 56 | } 57 | } 58 | 59 | // TestErrorComparison tests error equality and comparison 60 | func TestErrorComparison(t *testing.T) { 61 | t.Parallel() 62 | 63 | // Test that different errors are not equal 64 | assert.NotEqual(t, ErrAddressNotFound, ErrBlockNotFound) 65 | assert.NotEqual(t, ErrBSVChainRequired, ErrBTCChainRequired) 66 | 67 | // Test error identity with errors.Is 68 | assert.NotErrorIs(t, ErrAddressNotFound, ErrBlockNotFound) 69 | } 70 | 71 | // TestChainSpecificErrors tests chain-specific error behavior 72 | func TestChainSpecificErrors(t *testing.T) { 73 | t.Parallel() 74 | 75 | // Test BSV chain error 76 | assert.Contains(t, ErrBSVChainRequired.Error(), "BSV chain") 77 | assert.Contains(t, ErrBSVChainRequired.Error(), "operation is only available") 78 | 79 | // Test BTC chain error 80 | assert.Contains(t, ErrBTCChainRequired.Error(), "BTC chain") 81 | assert.Contains(t, ErrBTCChainRequired.Error(), "operation is only available") 82 | 83 | // Test chain errors are different 84 | assert.NotEqual(t, ErrBSVChainRequired, ErrBTCChainRequired) 85 | } 86 | 87 | // TestNotFoundErrors tests all "not found" type errors 88 | func TestNotFoundErrors(t *testing.T) { 89 | t.Parallel() 90 | 91 | notFoundErrors := []error{ 92 | ErrAddressNotFound, 93 | ErrBlockNotFound, 94 | ErrChainInfoNotFound, 95 | ErrChainTipsNotFound, 96 | ErrPeerInfoNotFound, 97 | ErrExchangeRateNotFound, 98 | ErrMempoolInfoNotFound, 99 | ErrHeadersNotFound, 100 | ErrScriptNotFound, 101 | ErrTransactionNotFound, 102 | ErrTokenNotFound, 103 | } 104 | 105 | for _, err := range notFoundErrors { 106 | assert.Contains(t, err.Error(), "not found") 107 | } 108 | } 109 | 110 | // TestLimitExceededErrors tests all limit exceeded errors 111 | func TestLimitExceededErrors(t *testing.T) { 112 | t.Parallel() 113 | 114 | limitErrors := []error{ 115 | ErrMaxAddressesExceeded, 116 | ErrMaxScriptsExceeded, 117 | ErrMaxTransactionsExceeded, 118 | ErrMaxPayloadSizeExceeded, 119 | ErrMaxTransactionSizeExceeded, 120 | ErrMaxUTXOsExceeded, 121 | ErrMaxRawTransactionsExceeded, 122 | } 123 | 124 | for _, err := range limitErrors { 125 | errMsg := err.Error() 126 | assert.True(t, 127 | contains(errMsg, "max") || contains(errMsg, "limit") || contains(errMsg, "exceeded"), 128 | "Expected error message to contain 'max', 'limit', or 'exceeded': %s", errMsg) 129 | } 130 | } 131 | 132 | // TestErrorWrapping tests error wrapping behavior 133 | func TestErrorWrapping(t *testing.T) { 134 | t.Parallel() 135 | 136 | // Test custom wrapping 137 | customErr := fmt.Errorf("custom: %w", ErrBlockNotFound) 138 | if !errors.Is(customErr, ErrBlockNotFound) { 139 | t.Errorf("expected error to wrap ErrBlockNotFound") 140 | } 141 | } 142 | 143 | // contains is a helper function to check if a string contains a substring 144 | func contains(s, substr string) bool { 145 | return len(s) >= len(substr) && (s == substr || len(substr) == 0 || 146 | (len(substr) > 0 && containsHelper(s, substr))) 147 | } 148 | 149 | func containsHelper(s, substr string) bool { 150 | for i := 0; i <= len(s)-len(substr); i++ { 151 | if s[i:i+len(substr)] == substr { 152 | return true 153 | } 154 | } 155 | return false 156 | } 157 | -------------------------------------------------------------------------------- /mempool_test.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | // mockHTTPMempoolValid for mocking requests 13 | type mockHTTPMempoolValid struct{} 14 | 15 | // Do is a mock http request 16 | func (m *mockHTTPMempoolValid) Do(req *http.Request) (*http.Response, error) { 17 | resp := new(http.Response) 18 | resp.StatusCode = http.StatusBadRequest 19 | 20 | // No req found 21 | if req == nil { 22 | return resp, ErrMissingRequest 23 | } 24 | 25 | // Valid 26 | if strings.Contains(req.URL.String(), "/mempool/info") { 27 | resp.StatusCode = http.StatusOK 28 | resp.Body = io.NopCloser(bytes.NewBufferString(`{"size": 520,"bytes": 108095,"usage": 549776,"maxmempool": 64000000000,"mempoolminfee": 0}`)) 29 | } 30 | 31 | // Valid 32 | if strings.Contains(req.URL.String(), "/mempool/raw") { 33 | resp.StatusCode = http.StatusOK 34 | resp.Body = io.NopCloser(bytes.NewBufferString(`["86806b3587956552ea0e3f09dfd14f485fc870fa319ab37e98289a5043234644","bd9e6c83f8fdcaa3b66b214a4fbf910976bd16ec926ab983a2367edfa3e2bbd9","9cf4450a20f91419623d9b461d4e47647ce3812f0fd2e2d2904c5f5a24e45bba"]`)) 35 | } 36 | 37 | // Default is valid 38 | return resp, nil 39 | } 40 | 41 | // mockHTTPMempoolInvalid for mocking requests 42 | type mockHTTPMempoolInvalid struct{} 43 | 44 | // Do is a mock http request 45 | func (m *mockHTTPMempoolInvalid) Do(req *http.Request) (*http.Response, error) { 46 | resp := new(http.Response) 47 | resp.StatusCode = http.StatusBadRequest 48 | 49 | // No req found 50 | if req == nil { 51 | return resp, ErrMissingRequest 52 | } 53 | 54 | // Invalid 55 | if strings.Contains(req.URL.String(), "/mempool/info") { 56 | resp.Body = io.NopCloser(bytes.NewBufferString("")) 57 | return resp, ErrBadRequest 58 | } 59 | 60 | // Invalid 61 | if strings.Contains(req.URL.String(), "/mempool/raw") { 62 | resp.Body = io.NopCloser(bytes.NewBufferString("")) 63 | return resp, ErrBadRequest 64 | } 65 | 66 | // Default is valid 67 | return resp, nil 68 | } 69 | 70 | // mockHTTPMempoolNotFound for mocking requests 71 | type mockHTTPMempoolNotFound struct{} 72 | 73 | // Do is a mock http request 74 | func (m *mockHTTPMempoolNotFound) Do(req *http.Request) (*http.Response, error) { 75 | resp := new(http.Response) 76 | resp.StatusCode = http.StatusNotFound 77 | 78 | // No req found 79 | if req == nil { 80 | return resp, ErrMissingRequest 81 | } 82 | 83 | // Not found 84 | if strings.Contains(req.URL.String(), "/mempool/info") { 85 | resp.Body = io.NopCloser(bytes.NewBufferString("")) 86 | return resp, nil 87 | } 88 | 89 | // Not found 90 | if strings.Contains(req.URL.String(), "/mempool/raw") { 91 | resp.Body = io.NopCloser(bytes.NewBufferString("")) 92 | return resp, nil 93 | } 94 | 95 | // Default is valid 96 | return resp, nil 97 | } 98 | 99 | // TestClient_GetMempoolInfo tests the GetMempoolInfo() 100 | func TestClient_GetMempoolInfo(t *testing.T) { 101 | t.Parallel() 102 | 103 | // New mock client 104 | client := newMockClient(&mockHTTPMempoolValid{}) 105 | ctx := context.Background() 106 | 107 | // Test the valid response 108 | info, err := client.GetMempoolInfo(ctx) 109 | if err != nil { 110 | t.Errorf("%s Failed: error [%s]", t.Name(), err.Error()) 111 | return 112 | } 113 | 114 | if info == nil { 115 | t.Errorf("%s Failed: info was nil", t.Name()) 116 | return 117 | } 118 | 119 | if info.Size != 520 { 120 | t.Errorf("%s Failed: size was [%d] expected [%d]", t.Name(), info.Size, 520) 121 | } 122 | 123 | if info.Bytes != 108095 { 124 | t.Errorf("%s Failed: bytes was [%d] expected [%d]", t.Name(), info.Bytes, 108095) 125 | } 126 | 127 | if info.MaxMempool != 64000000000 { 128 | t.Errorf("%s Failed: max mempool was [%d] expected [%d]", t.Name(), info.MaxMempool, 64000000000) 129 | } 130 | 131 | // New invalid mock client 132 | client = newMockClient(&mockHTTPMempoolInvalid{}) 133 | 134 | // Test invalid response 135 | _, err = client.GetMempoolInfo(ctx) 136 | if err == nil { 137 | t.Errorf("%s Failed: error should have occurred", t.Name()) 138 | } 139 | 140 | // New not found mock client 141 | client = newMockClient(&mockHTTPMempoolNotFound{}) 142 | 143 | // Test invalid response 144 | _, err = client.GetMempoolInfo(ctx) 145 | if err == nil { 146 | t.Errorf("%s Failed: error should have occurred", t.Name()) 147 | } 148 | } 149 | 150 | // TestClient_GetMempoolTransactions tests the GetMempoolTransactions() 151 | func TestClient_GetMempoolTransactions(t *testing.T) { 152 | t.Parallel() 153 | 154 | // New mock client 155 | client := newMockClient(&mockHTTPMempoolValid{}) 156 | ctx := context.Background() 157 | 158 | // Test the valid response 159 | info, err := client.GetMempoolTransactions(ctx) 160 | if err != nil { 161 | t.Errorf("%s Failed: error [%s]", t.Name(), err.Error()) 162 | } else if info == nil { 163 | t.Errorf("%s Failed: info was nil", t.Name()) 164 | } else if info[0] != "86806b3587956552ea0e3f09dfd14f485fc870fa319ab37e98289a5043234644" { 165 | t.Errorf("%s Failed: tx was [%s] expected [%s]", t.Name(), info[0], "86806b3587956552ea0e3f09dfd14f485fc870fa319ab37e98289a5043234644") 166 | } else if info[1] != "bd9e6c83f8fdcaa3b66b214a4fbf910976bd16ec926ab983a2367edfa3e2bbd9" { 167 | t.Errorf("%s Failed: tx was [%s] expected [%s]", t.Name(), info[1], "bd9e6c83f8fdcaa3b66b214a4fbf910976bd16ec926ab983a2367edfa3e2bbd9") 168 | } 169 | 170 | // New invalid mock client 171 | client = newMockClient(&mockHTTPMempoolInvalid{}) 172 | 173 | // Test invalid response 174 | _, err = client.GetMempoolTransactions(ctx) 175 | if err == nil { 176 | t.Errorf("%s Failed: error should have occurred", t.Name()) 177 | } 178 | 179 | // New not found mock client 180 | client = newMockClient(&mockHTTPMempoolNotFound{}) 181 | 182 | // Test invalid response 183 | _, err = client.GetMempoolTransactions(ctx) 184 | if err == nil { 185 | t.Errorf("%s Failed: error should have occurred", t.Name()) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /client_benchmark_test.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | // BenchmarkClientCreation benchmarks client creation with different option combinations 10 | func BenchmarkClientCreation(b *testing.B) { 11 | tests := []struct { 12 | name string 13 | opts []ClientOption 14 | }{ 15 | { 16 | name: "Minimal", 17 | opts: []ClientOption{}, 18 | }, 19 | { 20 | name: "WithChainAndNetwork", 21 | opts: []ClientOption{ 22 | WithChain(ChainBSV), 23 | WithNetwork(NetworkMain), 24 | }, 25 | }, 26 | { 27 | name: "FullyConfigured", 28 | opts: []ClientOption{ 29 | WithChain(ChainBSV), 30 | WithNetwork(NetworkMain), 31 | WithAPIKey("test-api-key"), 32 | WithUserAgent("test-agent/1.0"), 33 | WithRateLimit(10), 34 | WithRequestTimeout(30 * time.Second), 35 | WithRequestRetryCount(2), 36 | WithBackoff(2*time.Millisecond, 10*time.Millisecond, 2.0, 2*time.Millisecond), 37 | WithDialer(20*time.Second, 5*time.Second), 38 | WithTransport(20*time.Second, 5*time.Second, 3*time.Second, 10), 39 | }, 40 | }, 41 | } 42 | 43 | for _, tt := range tests { 44 | b.Run(tt.name, func(b *testing.B) { 45 | b.ReportAllocs() 46 | ctx := context.Background() 47 | for i := 0; i < b.N; i++ { 48 | client, err := NewClient(ctx, tt.opts...) 49 | if err != nil { 50 | b.Fatal(err) 51 | } 52 | _ = client 53 | } 54 | }) 55 | } 56 | } 57 | 58 | // BenchmarkClientGetters benchmarks the getter methods 59 | func BenchmarkClientGetters(b *testing.B) { 60 | client, _ := NewClient(context.Background(), 61 | WithChain(ChainBSV), 62 | WithNetwork(NetworkMain), 63 | WithAPIKey("test-key"), 64 | WithUserAgent("test-agent"), 65 | WithRateLimit(5), 66 | ) 67 | 68 | tests := []struct { 69 | name string 70 | fn func() 71 | }{ 72 | {"Chain", func() { _ = client.Chain() }}, 73 | {"Network", func() { _ = client.Network() }}, 74 | {"UserAgent", func() { _ = client.UserAgent() }}, 75 | {"RateLimit", func() { _ = client.RateLimit() }}, 76 | {"APIKey", func() { _ = client.APIKey() }}, 77 | {"RequestTimeout", func() { _ = client.RequestTimeout() }}, 78 | {"RequestRetryCount", func() { _ = client.RequestRetryCount() }}, 79 | {"LastRequest", func() { _ = client.LastRequest() }}, 80 | {"HTTPClient", func() { _ = client.HTTPClient() }}, 81 | } 82 | 83 | for _, tt := range tests { 84 | b.Run(tt.name, func(b *testing.B) { 85 | b.ReportAllocs() 86 | for i := 0; i < b.N; i++ { 87 | tt.fn() 88 | } 89 | }) 90 | } 91 | } 92 | 93 | // BenchmarkClientSetters benchmarks the setter methods 94 | func BenchmarkClientSetters(b *testing.B) { 95 | tests := []struct { 96 | name string 97 | fn func(*Client) 98 | }{ 99 | {"SetChain", func(c *Client) { c.SetChain(ChainBSV) }}, 100 | {"SetNetwork", func(c *Client) { c.SetNetwork(NetworkMain) }}, 101 | {"SetAPIKey", func(c *Client) { c.SetAPIKey("test-key") }}, 102 | {"SetUserAgent", func(c *Client) { c.SetUserAgent("agent") }}, 103 | {"SetRateLimit", func(c *Client) { c.SetRateLimit(5) }}, 104 | {"SetRequestTimeout", func(c *Client) { c.SetRequestTimeout(30 * time.Second) }}, 105 | {"SetRequestRetryCount", func(c *Client) { c.SetRequestRetryCount(3) }}, 106 | } 107 | 108 | for _, tt := range tests { 109 | b.Run(tt.name, func(b *testing.B) { 110 | b.ReportAllocs() 111 | client, _ := NewClient(context.Background()) 112 | c := client.(*Client) 113 | for i := 0; i < b.N; i++ { 114 | tt.fn(c) 115 | } 116 | }) 117 | } 118 | } 119 | 120 | // BenchmarkBuildURL benchmarks the URL building (hot path) 121 | func BenchmarkBuildURL(b *testing.B) { 122 | client, _ := NewClient(context.Background(), 123 | WithChain(ChainBSV), 124 | WithNetwork(NetworkMain), 125 | ) 126 | c := client.(*Client) 127 | 128 | tests := []struct { 129 | name string 130 | path string 131 | args []interface{} 132 | }{ 133 | { 134 | name: "Simple", 135 | path: "/chain/info", 136 | args: nil, 137 | }, 138 | { 139 | name: "WithOneArg", 140 | path: "/tx/hash/%s", 141 | args: []interface{}{"abc123def456"}, 142 | }, 143 | { 144 | name: "WithTwoArgs", 145 | path: "/block/hash/%s/page/%d", 146 | args: []interface{}{"abc123def456", 1}, 147 | }, 148 | { 149 | name: "LongAddress", 150 | path: "/address/%s/balance", 151 | args: []interface{}{"16ZqP5Tb22KJuvSAbjNkoiZs13mmRmexZA"}, 152 | }, 153 | } 154 | 155 | for _, tt := range tests { 156 | b.Run(tt.name, func(b *testing.B) { 157 | b.ReportAllocs() 158 | for i := 0; i < b.N; i++ { 159 | _ = c.buildURL(tt.path, tt.args...) 160 | } 161 | }) 162 | } 163 | } 164 | 165 | // BenchmarkBackoffConfig benchmarks backoff configuration operations 166 | func BenchmarkBackoffConfig(b *testing.B) { 167 | client, _ := NewClient(context.Background()) 168 | c := client.(*Client) 169 | 170 | b.Run("Get", func(b *testing.B) { 171 | b.ReportAllocs() 172 | for i := 0; i < b.N; i++ { 173 | _, _, _, _ = c.BackoffConfig() 174 | } 175 | }) 176 | 177 | b.Run("Set", func(b *testing.B) { 178 | b.ReportAllocs() 179 | for i := 0; i < b.N; i++ { 180 | c.SetBackoffConfig(2*time.Millisecond, 10*time.Millisecond, 2.0, 2*time.Millisecond) 181 | } 182 | }) 183 | } 184 | 185 | // BenchmarkDialerConfig benchmarks dialer configuration operations 186 | func BenchmarkDialerConfig(b *testing.B) { 187 | client, _ := NewClient(context.Background()) 188 | c := client.(*Client) 189 | 190 | b.Run("Get", func(b *testing.B) { 191 | b.ReportAllocs() 192 | for i := 0; i < b.N; i++ { 193 | _, _ = c.DialerConfig() 194 | } 195 | }) 196 | 197 | b.Run("Set", func(b *testing.B) { 198 | b.ReportAllocs() 199 | for i := 0; i < b.N; i++ { 200 | c.SetDialerConfig(20*time.Second, 5*time.Second) 201 | } 202 | }) 203 | } 204 | 205 | // BenchmarkTransportConfig benchmarks transport configuration operations 206 | func BenchmarkTransportConfig(b *testing.B) { 207 | client, _ := NewClient(context.Background()) 208 | c := client.(*Client) 209 | 210 | b.Run("Get", func(b *testing.B) { 211 | b.ReportAllocs() 212 | for i := 0; i < b.N; i++ { 213 | _, _, _, _ = c.TransportConfig() 214 | } 215 | }) 216 | 217 | b.Run("Set", func(b *testing.B) { 218 | b.ReportAllocs() 219 | for i := 0; i < b.N; i++ { 220 | c.SetTransportConfig(20*time.Second, 5*time.Second, 3*time.Second, 10) 221 | } 222 | }) 223 | } 224 | -------------------------------------------------------------------------------- /.github/tech-conventions/dependency-management.md: -------------------------------------------------------------------------------- 1 | # Dependency Management 2 | 3 | > Dependency hygiene is critical for security, reproducibility, and developer experience. Follow these practices to ensure our module stays stable, up to date, and secure. 4 | 5 |

6 | 7 | ## 📦 Module Management 8 | 9 | * All dependencies must be managed via **Go Modules** (`go.mod`, `go.sum`) 10 | 11 | * After adding, updating, or removing imports, run: 12 | 13 | ```bash 14 | go mod tidy 15 | ``` 16 | 17 | or via magex command: 18 | 19 | ```bash 20 | magex deps:tidy 21 | ``` 22 | 23 | * Periodically refresh dependencies with: 24 | 25 | ```bash 26 | go get -u ./... 27 | ``` 28 | 29 | or via magex command: 30 | 31 | ```bash 32 | magex deps:update 33 | ``` 34 | 35 | > Avoid unnecessary upgrades near release windows—review major version bumps carefully for breaking changes. 36 | 37 |

38 | 39 | ## 🛡️ Security Scanning 40 | 41 | * Use [govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) to identify known vulnerabilities: 42 | ```bash 43 | govulncheck ./... 44 | ``` 45 | 46 | * Run via magex command: 47 | ```bash 48 | magex deps:audit 49 | ``` 50 | 51 | * Run [gitleaks](https://github.com/gitleaks/gitleaks) before committing code to detect hardcoded secrets or sensitive data in the repository: 52 | ```bash 53 | brew install gitleaks 54 | gitleaks detect --source . --log-opts="--all" --verbose 55 | ``` 56 | 57 | * Address critical advisories before merging changes into `main/master` 58 | 59 | * Document any intentionally ignored vulnerabilities with clear justification and issue tracking 60 | 61 | * We follow the [OpenSSF](https://openssf.org) best practices to ensure this repository remains compliant with industry‑standard open source security guidelines 62 | 63 |

64 | 65 | ## 📁 Version Control 66 | 67 | * Never manually edit `go.sum` 68 | * Do not vendor dependencies; we rely on modules for reproducibility 69 | * Lockstep upgrades across repos (when applicable) should be coordinated and noted in PRs 70 | 71 | > Changes to dependencies should be explained in the PR description and ideally linked to the reason (e.g., bug fix, security advisory, feature requirement). 72 | 73 |

74 | 75 | ## 🔄 Dependency Update Workflow 76 | 77 | ### Regular Updates 78 | 1. **Check for updates** 79 | ```bash 80 | go list -u -m all 81 | ``` 82 | 83 | 2. **Update minor/patch versions** 84 | ```bash 85 | go get -u ./... 86 | go mod tidy 87 | ``` 88 | 89 | 3. **Test thoroughly** 90 | ```bash 91 | magex test 92 | magex test:race 93 | magex bench 94 | ``` 95 | 96 | 4. **Security scan** 97 | ```bash 98 | magex deps:audit 99 | ``` 100 | 101 | ### Major Version Updates 102 | 1. **Review breaking changes** in release notes 103 | 2. **Update import paths** if required 104 | 3. **Fix compilation errors** 105 | 4. **Update tests** for new behavior 106 | 5. **Document in PR** what changed and why 107 | 108 |

109 | 110 | ## 🤖 Automated Dependency Management 111 | 112 | ### Dependabot Configuration 113 | * Configured in `.github/dependabot.yml` 114 | * Checks for updates weekly 115 | * Groups minor/patch updates 116 | * Creates separate PRs for major versions 117 | 118 | ### Auto-merge Rules 119 | * Minor/patch updates with passing CI can auto-merge 120 | * Major updates require manual review 121 | * Security updates prioritized for review 122 | 123 |

124 | 125 | ## 📊 Dependency Analysis 126 | 127 | ### Check dependency graph 128 | ```bash 129 | go mod graph 130 | ``` 131 | 132 | ### Identify unused dependencies 133 | ```bash 134 | go mod tidy -v 135 | ``` 136 | 137 | ### Analyze module size impact 138 | ```bash 139 | go mod download -json | jq '.Dir' | xargs du -sh | sort -h 140 | ``` 141 | 142 |

143 | 144 | ## 🚫 Dependency Guidelines 145 | 146 | ### DO: 147 | * **Pin to specific versions** in production 148 | * **Review licenses** before adding dependencies 149 | * **Prefer standard library** when possible 150 | * **Use minimal dependencies** for core functionality 151 | * **Document unusual dependencies** in code comments 152 | 153 | ### DON'T: 154 | * **Use `latest` tags** in production 155 | * **Import unused packages** 156 | * **Use replace directives** except for emergencies 157 | * **Add dependencies for trivial functionality** 158 | * **Ignore security advisories** 159 | 160 |

161 | 162 | ## 🔍 Evaluating New Dependencies 163 | 164 | Before adding a new dependency, consider: 165 | 166 | 1. **Necessity**: Can we implement this ourselves simply? 167 | 2. **Maintenance**: Is the project actively maintained? 168 | 3. **Security**: Any known vulnerabilities? 169 | 4. **License**: Compatible with our project? 170 | 5. **Size**: How much does it increase binary size? 171 | 6. **Quality**: Well-tested? Good documentation? 172 | 7. **Dependencies**: Does it bring many transitive dependencies? 173 | 174 |

175 | 176 | ## 📝 Replace Directives 177 | 178 | Use `replace` only when absolutely necessary: 179 | 180 | ```go 181 | // Temporary fix for critical bug until upstream releases 182 | replace github.com/broken/package v1.2.3 => github.com/fork/package v1.2.4-fixed 183 | 184 | // Local development only - remove before committing 185 | replace github.com/company/module => ../local-module 186 | ``` 187 | 188 | Document why the replacement is needed and track removal in an issue. 189 | 190 |

191 | 192 | ## 🔐 Private Dependencies 193 | 194 | For private modules: 195 | 196 | 1. **Configure authentication** 197 | ```bash 198 | git config --global url."git@github.com:company/".insteadOf "https://github.com/company/" 199 | ``` 200 | 201 | 2. **Set GOPRIVATE** 202 | ```bash 203 | export GOPRIVATE=github.com/company/* 204 | ``` 205 | 206 | 3. **Document setup** in README for team members 207 | 208 |

209 | 210 | ## 📈 Monitoring Dependencies 211 | 212 | ### Track outdated dependencies 213 | ```bash 214 | # Show available updates 215 | go list -u -m all | grep '\[' 216 | 217 | # Count total dependencies 218 | go mod graph | wc -l 219 | ``` 220 | 221 | ### Review dependency changes 222 | ```bash 223 | # See what changed in go.mod 224 | git diff go.mod 225 | 226 | # Detailed view of go.sum changes 227 | git diff go.sum | grep '^[+-]' | sort 228 | ``` 229 | 230 | > Regular dependency maintenance prevents security issues and reduces upgrade complexity. 231 | -------------------------------------------------------------------------------- /http_client.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "io" 7 | "math" 8 | "math/big" 9 | "net/http" 10 | "time" 11 | ) 12 | 13 | // ExponentialBackoff provides exponential backoff functionality 14 | type ExponentialBackoff struct { 15 | initialTimeout time.Duration 16 | maxTimeout time.Duration 17 | exponentFactor float64 18 | maxJitterInterval time.Duration 19 | } 20 | 21 | // NewExponentialBackoff creates a new exponential backoff instance 22 | func NewExponentialBackoff(initialTimeout, maxTimeout time.Duration, exponentFactor float64, maxJitterInterval time.Duration) *ExponentialBackoff { 23 | return &ExponentialBackoff{ 24 | initialTimeout: initialTimeout, 25 | maxTimeout: maxTimeout, 26 | exponentFactor: exponentFactor, 27 | maxJitterInterval: maxJitterInterval, 28 | } 29 | } 30 | 31 | // NextInterval calculates the next backoff interval for the given attempt 32 | func (eb *ExponentialBackoff) NextInterval(attempt int) time.Duration { 33 | if attempt < 0 { 34 | attempt = 0 35 | } 36 | 37 | // Calculate exponential backoff: initialTimeout * (exponentFactor ^ attempt) 38 | backoffDuration := float64(eb.initialTimeout) * math.Pow(eb.exponentFactor, float64(attempt)) 39 | 40 | // Cap at maxTimeout 41 | if backoffDuration > float64(eb.maxTimeout) { 42 | backoffDuration = float64(eb.maxTimeout) 43 | } 44 | 45 | // Add jitter to prevent thundering herd 46 | var jitter time.Duration 47 | if eb.maxJitterInterval > 0 { 48 | // Use crypto/rand for better security 49 | if maxJitter := big.NewInt(int64(eb.maxJitterInterval)); maxJitter.Int64() > 0 { 50 | if n, err := rand.Int(rand.Reader, maxJitter); err == nil { 51 | jitter = time.Duration(n.Int64()) 52 | } 53 | } 54 | } 55 | 56 | return time.Duration(backoffDuration) + jitter 57 | } 58 | 59 | // RetryableHTTPClient is a native Go HTTP client with retry capability 60 | type RetryableHTTPClient struct { 61 | client *http.Client 62 | retryCount int 63 | backoff *ExponentialBackoff 64 | } 65 | 66 | // NewRetryableHTTPClient creates a new retryable HTTP client 67 | func NewRetryableHTTPClient(httpClient *http.Client, retryCount int, backoff *ExponentialBackoff) *RetryableHTTPClient { 68 | if httpClient == nil { 69 | httpClient = &http.Client{} 70 | } 71 | 72 | return &RetryableHTTPClient{ 73 | client: httpClient, 74 | retryCount: retryCount, 75 | backoff: backoff, 76 | } 77 | } 78 | 79 | // Do will execute an HTTP request with retry logic 80 | func (r *RetryableHTTPClient) Do(req *http.Request) (*http.Response, error) { 81 | var lastResp *http.Response 82 | var lastErr error 83 | 84 | // If no retries configured, just execute once 85 | if r.retryCount <= 0 { 86 | return r.client.Do(req) 87 | } 88 | 89 | // Read and store the request body once so we can reuse it for retries 90 | var bodyBytes []byte 91 | if req.Body != nil { 92 | var err error 93 | bodyBytes, err = io.ReadAll(req.Body) 94 | if err != nil { 95 | return nil, err 96 | } 97 | _ = req.Body.Close() 98 | } 99 | 100 | maxAttempts := r.retryCount + 1 // retryCount doesn't include the initial attempt 101 | 102 | for attempt := 0; attempt < maxAttempts; attempt++ { 103 | // Create a new request for each attempt 104 | var reqForAttempt *http.Request 105 | var err error 106 | 107 | if bodyBytes != nil { 108 | // Create new request with fresh body 109 | reqForAttempt, err = http.NewRequestWithContext( 110 | req.Context(), 111 | req.Method, 112 | req.URL.String(), 113 | bytes.NewReader(bodyBytes), 114 | ) 115 | } else { 116 | // There is no "body", just clone the request 117 | reqForAttempt, err = http.NewRequestWithContext( 118 | req.Context(), 119 | req.Method, 120 | req.URL.String(), 121 | nil, 122 | ) 123 | } 124 | 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | // Copy headers from original request 130 | for key, values := range req.Header { 131 | for _, value := range values { 132 | reqForAttempt.Header.Add(key, value) 133 | } 134 | } 135 | 136 | // Execute the request 137 | var resp *http.Response 138 | resp, err = r.client.Do(reqForAttempt) 139 | 140 | // If this is the last attempt, return whatever we got 141 | if attempt == maxAttempts-1 { 142 | return resp, err 143 | } 144 | 145 | // Check if we should retry 146 | if !r.shouldRetry(resp, err) { 147 | return resp, err 148 | } 149 | 150 | // Store the response/error for potential return 151 | lastResp = resp 152 | lastErr = err 153 | 154 | // Close the response body if it exists (to free up the connection) 155 | if resp != nil && resp.Body != nil { 156 | _ = resp.Body.Close() 157 | } 158 | 159 | // Calculate backoff duration 160 | var backoffDuration time.Duration 161 | if r.backoff != nil { 162 | backoffDuration = r.backoff.NextInterval(attempt) 163 | } else { 164 | // Default exponential backoff if none provided 165 | backoffDuration = time.Duration(math.Pow(2, float64(attempt))) * time.Millisecond * 100 166 | } 167 | 168 | // Wait before retrying, respecting context cancellation 169 | select { 170 | case <-req.Context().Done(): 171 | return lastResp, req.Context().Err() 172 | case <-time.After(backoffDuration): 173 | // Continue to next attempt 174 | } 175 | } 176 | 177 | return lastResp, lastErr 178 | } 179 | 180 | // shouldRetry determines if a request should be retried based on the response 181 | func (r *RetryableHTTPClient) shouldRetry(resp *http.Response, err error) bool { 182 | // Retry on network errors 183 | if err != nil { 184 | return true 185 | } 186 | 187 | // Retry on server errors (5xx) and specific client errors 188 | if resp != nil { 189 | switch resp.StatusCode { 190 | case http.StatusInternalServerError, // 500 191 | http.StatusBadGateway, // 502 192 | http.StatusServiceUnavailable, // 503 193 | http.StatusGatewayTimeout, // 504 194 | http.StatusTooManyRequests: // 429 195 | return true 196 | } 197 | } 198 | 199 | return false 200 | } 201 | 202 | // SimpleHTTPClient is a simple wrapper around http.Client for non-retry scenarios 203 | type SimpleHTTPClient struct { 204 | client *http.Client 205 | } 206 | 207 | // NewSimpleHTTPClient creates a new simple HTTP client wrapper 208 | func NewSimpleHTTPClient(httpClient *http.Client) *SimpleHTTPClient { 209 | if httpClient == nil { 210 | httpClient = &http.Client{} 211 | } 212 | 213 | return &SimpleHTTPClient{ 214 | client: httpClient, 215 | } 216 | } 217 | 218 | // Do executes an HTTP request without retry logic 219 | func (s *SimpleHTTPClient) Do(req *http.Request) (*http.Response, error) { 220 | return s.client.Do(req) 221 | } 222 | -------------------------------------------------------------------------------- /.github/tech-conventions/security-practices.md: -------------------------------------------------------------------------------- 1 | # Security Practices 2 | 3 | > Security is a first-class requirement. This document outlines security practices, vulnerability reporting, and tools used to maintain a secure codebase. 4 | 5 |

6 | 7 | ## 🛡️ Vulnerability Reporting 8 | 9 | If you discover a vulnerability—no matter how small—follow our responsible disclosure process: 10 | 11 | * **Do not** open a public issue or pull request. 12 | * Follow the instructions in [`SECURITY.md`](../SECURITY.md). 13 | * Include: 14 | * A clear, reproducible description of the issue 15 | * Proof‑of‑concept code or steps (if possible) 16 | * Any known mitigations or workarounds 17 | * You will receive an acknowledgment within **72 hours** and status updates until the issue is resolved. 18 | 19 | > For general hardening guidance (e.g., `govulncheck`, dependency pinning), see the [Dependency Management](dependency-management.md) section. 20 | 21 |

22 | 23 | ## 🔐 Security Tools 24 | 25 | ### Required Security Scans 26 | 27 | 1. **govulncheck** - Go vulnerability database scanning 28 | ```bash 29 | magex deps:audit 30 | ``` 31 | 32 | 2. **gitleaks** - Secret detection in code 33 | ```bash 34 | gitleaks detect --source . --log-opts="--all" --verbose 35 | ``` 36 | 37 | 3. **CodeQL** - Semantic code analysis (runs in CI) 38 | - Automated via `.github/workflows/codeql-analysis.yml` 39 | - Scans for common vulnerabilities 40 | 41 |

42 | 43 | ## 🚫 Security Anti-Patterns 44 | 45 | ### Never Do This: 46 | 47 | ```go 48 | // 🚫 Never hardcode secrets 49 | // apiKey := "1234..." 50 | 51 | // 🚫 Never log sensitive data 52 | // log.Printf("User password: %s", password) 53 | 54 | // 🚫 Never use weak cryptography 55 | hash := md5.Sum([]byte(data)) // MD5 is broken 56 | 57 | // 🚫 Never trust user input without validation 58 | query := fmt.Sprintf("SELECT * FROM users WHERE id = %s", userInput) 59 | 60 | // 🚫 Never ignore security errors 61 | cert, _ := tls.LoadX509KeyPair(certFile, keyFile) // Always check errors! 62 | ``` 63 | 64 | ### Always Do This: 65 | 66 | ```go 67 | // ✅ Use environment variables for secrets 68 | apiKey := os.Getenv("API_KEY") 69 | if apiKey == "" { 70 | return errors.New("API_KEY environment variable not set") 71 | } 72 | 73 | // ✅ Sanitize logs 74 | log.Printf("User authentication attempt for ID: %s", userID) 75 | 76 | // ✅ Use strong cryptography 77 | hash := sha256.Sum256([]byte(data)) 78 | 79 | // ✅ Use parameterized queries 80 | query := "SELECT * FROM users WHERE id = ?" 81 | rows, err := db.QueryContext(ctx, query, userInput) 82 | 83 | // ✅ Always handle security-critical errors 84 | cert, err := tls.LoadX509KeyPair(certFile, keyFile) 85 | if err != nil { 86 | return fmt.Errorf("failed to load TLS certificate: %w", err) 87 | } 88 | ``` 89 | 90 |

91 | 92 | ## 🔒 Secure Coding Practices 93 | 94 | ### Input Validation 95 | * **Validate all inputs** at trust boundaries 96 | * **Use allowlists** over denylists when possible 97 | * **Sanitize** before using in queries, commands, or output 98 | * **Set limits** on input size and complexity 99 | 100 | ### Authentication & Authorization 101 | * **Use standard libraries** for crypto operations 102 | * **Never roll your own crypto** 103 | * **Store passwords** using bcrypt, scrypt, or argon2 104 | * **Implement proper session management** 105 | * **Use constant-time comparisons** for secrets 106 | 107 | ### Error Handling 108 | * **Don't leak sensitive info** in error messages 109 | * **Log security events** for monitoring 110 | * **Fail securely** - deny by default 111 | * **Handle panics** in goroutines 112 | 113 |

114 | 115 | ## 📋 Security Checklist 116 | 117 | Before committing code, verify: 118 | 119 | - [ ] No hardcoded secrets or credentials 120 | - [ ] All user inputs are validated 121 | - [ ] SQL queries use parameters, not string concatenation 122 | - [ ] File paths are sanitized before use 123 | - [ ] Proper error handling without info leakage 124 | - [ ] Dependencies are up to date 125 | - [ ] Security scans pass (govulncheck, gitleaks) 126 | 127 |

128 | 129 | ## 🚨 Incident Response 130 | 131 | If a security issue is found in production: 132 | 133 | 1. **Don't panic** - Follow the process 134 | 2. **Assess severity** using CVSS scoring 135 | 3. **Notify security team** immediately 136 | 4. **Create private fix** in security fork 137 | 5. **Test thoroughly** including regression tests 138 | 6. **Coordinate disclosure** with security team 139 | 7. **Release patch** with security advisory 140 | 8. **Monitor** for exploitation attempts 141 | 142 |

143 | 144 | ## 🏗️ Security by Design 145 | 146 | ### Principle of The Least Privilege 147 | * Run processes with minimal permissions 148 | * Use read-only file systems where possible 149 | * Drop privileges after initialization 150 | * Segment access by service boundaries 151 | 152 | ### Defense in Depth 153 | * Multiple layers of security controls 154 | * Don't rely on a single security measure 155 | * Assume other defenses may fail 156 | * Monitor and alert on anomalies 157 | 158 | ### Secure Defaults 159 | * Deny by default, allow explicitly 160 | * Require secure configuration 161 | * Force HTTPS/TLS connections 162 | * Enable security features by default 163 | 164 |

165 | 166 | ## 📊 OpenSSF Best Practices 167 | 168 | We follow [OpenSSF](https://openssf.org) guidelines: 169 | 170 | 1. **Vulnerability Disclosure** - Clear security policy 171 | 2. **Dependency Maintenance** - Regular updates 172 | 3. **Security Testing** - Automated scanning 173 | 4. **Access Control** - Protected branches 174 | 5. **Build Integrity** - Signed releases 175 | 6. **Code Review** - Required for all changes 176 | 177 | ### Scorecard Compliance 178 | Monitor security posture via: 179 | ```bash 180 | # Check OpenSSF Scorecard 181 | scorecard --repo=github.com/owner/repo 182 | ``` 183 | 184 |

185 | 186 | ## 🔍 Security Reviews 187 | 188 | ### When to Request Review 189 | * Cryptographic implementations 190 | * Authentication/authorization changes 191 | * Handling sensitive data 192 | * Network-facing services 193 | * File system operations 194 | * Subprocess execution 195 | 196 | ### Review Focus Areas 197 | * Input validation completeness 198 | * Output encoding correctness 199 | * Error handling safety 200 | * Resource limit enforcement 201 | * Permission boundaries 202 | * Audit logging coverage 203 | 204 |

205 | 206 | ## 📚 Security Resources 207 | 208 | ### Internal Resources 209 | * [`SECURITY.md`](../SECURITY.md) - Vulnerability reporting 210 | * [Dependency Management](dependency-management.md) - Supply chain security 211 | 212 | ### External Resources 213 | * [OWASP Top 10](https://owasp.org/Top10/) 214 | * [Go Security Best Practices](https://golang.org/doc/security) 215 | * [CWE Database](https://cwe.mitre.org/) 216 | * [NIST Cybersecurity Framework](https://www.nist.gov/cyberframework) 217 | -------------------------------------------------------------------------------- /.github/workflows/fortress-warm-cache.yml: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------------ 2 | # Cache Warming (Reusable Workflow) (GoFortress) 3 | # 4 | # Purpose: Warm Go module and Redis caches across multiple Go versions and operating 5 | # systems to optimize subsequent workflow performance. 6 | # 7 | # Maintainer: @mrz1836 8 | # 9 | # ------------------------------------------------------------------------------------ 10 | 11 | name: GoFortress (Cache Warming) 12 | 13 | on: 14 | workflow_call: 15 | inputs: 16 | env-json: 17 | description: "JSON string of environment variables" 18 | required: true 19 | type: string 20 | warm-cache-matrix: 21 | description: "Cache warming matrix JSON" 22 | required: true 23 | type: string 24 | go-primary-version: 25 | description: "Primary Go version" 26 | required: true 27 | type: string 28 | go-secondary-version: 29 | description: "Secondary Go version" 30 | required: true 31 | type: string 32 | redis-enabled: 33 | description: "Whether Redis service is enabled" 34 | required: false 35 | type: string 36 | default: "false" 37 | redis-version: 38 | description: "Redis Docker image version" 39 | required: false 40 | type: string 41 | default: "7-alpine" 42 | redis-cache-force-pull: 43 | description: "Force pull Redis image instead of using cache" 44 | required: false 45 | type: string 46 | default: "false" 47 | go-sum-file: 48 | description: "Path to go.sum file for dependency verification" 49 | required: true 50 | type: string 51 | 52 | # Security: Restrictive default permissions with job-level overrides for least privilege access 53 | permissions: 54 | contents: read 55 | 56 | jobs: 57 | # ---------------------------------------------------------------------------------- 58 | # Warm Cache Matrix (Parallel) 59 | # ---------------------------------------------------------------------------------- 60 | warm-cache-matrix: 61 | name: 💾 Warm Cache (${{ matrix.name }}) 62 | strategy: 63 | fail-fast: true 64 | matrix: ${{ fromJSON(inputs.warm-cache-matrix) }} 65 | runs-on: ${{ matrix.os }} 66 | steps: 67 | # -------------------------------------------------------------------- 68 | # Parse environment variables 69 | # -------------------------------------------------------------------- 70 | - name: 🔧 Parse environment variables 71 | env: 72 | ENV_JSON: ${{ inputs.env-json }} 73 | run: | 74 | echo "📋 Setting environment variables..." 75 | echo "$ENV_JSON" | jq -r 'to_entries | .[] | "\(.key)=\(.value)"' | while IFS='=' read -r key value; do 76 | echo "$key=$value" >> $GITHUB_ENV 77 | done 78 | 79 | # -------------------------------------------------------------------- 80 | # Derive go.mod path from go.sum path for sparse checkout 81 | # -------------------------------------------------------------------- 82 | - name: 🔧 Derive module paths 83 | id: module-paths 84 | env: 85 | GO_SUM_FILE: ${{ inputs.go-sum-file }} 86 | run: | 87 | # Derive go.mod path from go.sum path (same directory) 88 | GO_MOD_FILE="${GO_SUM_FILE%go.sum}go.mod" 89 | echo "go_mod_file=$GO_MOD_FILE" >> "$GITHUB_OUTPUT" 90 | echo "📁 Go module file: $GO_MOD_FILE" 91 | echo "📁 Go sum file: $GO_SUM_FILE" 92 | 93 | # -------------------------------------------------------------------- 94 | # Extract configuration flags from env-json (before checkout) 95 | # -------------------------------------------------------------------- 96 | - name: 🔁 Extract configuration flags 97 | id: extract 98 | run: | 99 | echo "enable_verbose=$(echo '${{ inputs.env-json }}' | jq -r '.ENABLE_VERBOSE_TEST_OUTPUT')" >> "$GITHUB_OUTPUT" 100 | echo "enable_multi_module=$(echo '${{ inputs.env-json }}' | jq -r '.ENABLE_MULTI_MODULE_TESTING // "false"')" >> "$GITHUB_OUTPUT" 101 | echo "📋 Multi-module testing: $(echo '${{ inputs.env-json }}' | jq -r '.ENABLE_MULTI_MODULE_TESTING // "false"')" 102 | 103 | # -------------------------------------------------------------------- 104 | # Checkout code - full checkout for multi-module, sparse for single 105 | # Multi-module needs all source files + go.work for cross-module deps 106 | # -------------------------------------------------------------------- 107 | - name: 📥 Checkout code (full - multi-module) 108 | if: steps.extract.outputs.enable_multi_module == 'true' 109 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 110 | with: 111 | persist-credentials: false 112 | 113 | - name: 📥 Checkout code (sparse - single module) 114 | if: steps.extract.outputs.enable_multi_module != 'true' 115 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 116 | with: 117 | persist-credentials: false 118 | sparse-checkout: | 119 | .github/actions/warm-cache 120 | .github/actions/warm-redis-cache 121 | .github/actions/cache-redis-image 122 | .github/actions/setup-go-with-cache 123 | .github/actions/setup-magex 124 | .github/.env.base 125 | ${{ steps.module-paths.outputs.go_mod_file }} 126 | ${{ inputs.go-sum-file }} 127 | .mage.yaml 128 | 129 | # -------------------------------------------------------------------- 130 | # Disable Go workspace mode (single module only) 131 | # Multi-module mode uses go.work for cross-module dependency resolution 132 | # -------------------------------------------------------------------- 133 | - name: 🔧 Disable Go workspace mode 134 | if: steps.extract.outputs.enable_multi_module != 'true' 135 | run: echo "GOWORK=off" >> $GITHUB_ENV 136 | 137 | # -------------------------------------------------------------------- 138 | # Warm the Go and Redis caches using local action 139 | # -------------------------------------------------------------------- 140 | - name: 🔥 Warm Go and Redis Caches 141 | uses: ./.github/actions/warm-cache 142 | with: 143 | go-version: ${{ matrix.go-version }} 144 | matrix-os: ${{ matrix.os }} 145 | matrix-name: ${{ matrix.name }} 146 | enable-verbose: ${{ steps.extract.outputs.enable_verbose }} 147 | go-primary-version: ${{ inputs.go-primary-version }} 148 | go-secondary-version: ${{ inputs.go-secondary-version }} 149 | env-json: ${{ inputs.env-json }} 150 | redis-enabled: ${{ inputs.redis-enabled }} 151 | redis-versions: ${{ inputs.redis-version }} 152 | redis-cache-force-pull: ${{ inputs.redis-cache-force-pull }} 153 | go-sum-file: ${{ inputs.go-sum-file }} 154 | enable-multi-module: ${{ steps.extract.outputs.enable_multi_module }} 155 | -------------------------------------------------------------------------------- /tokens.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | // GetOneSatOrdinalByOrigin gets a 1Sat Ordinal token by origin (BSV-only endpoint) 9 | // 10 | // For more information: https://docs.whatsonchain.com/api/tokens/1sat-ordinals#get-token-by-origin 11 | func (c *Client) GetOneSatOrdinalByOrigin(ctx context.Context, origin string) (*OneSatOrdinalToken, error) { 12 | if c.Chain() != ChainBSV { 13 | return nil, ErrBSVChainRequired 14 | } 15 | 16 | url := c.buildURL("/token/1satordinals/%s/origin", origin) 17 | token, err := requestAndUnmarshal[OneSatOrdinalToken](ctx, c, url, http.MethodGet, nil, nil) 18 | if err != nil { 19 | return nil, err 20 | } 21 | if c.LastRequest().StatusCode == http.StatusNotFound { 22 | return nil, ErrTokenNotFound 23 | } 24 | return token, nil 25 | } 26 | 27 | // GetOneSatOrdinalByOutpoint gets a 1Sat Ordinal token by outpoint (BSV-only endpoint) 28 | // 29 | // For more information: https://docs.whatsonchain.com/api/tokens/1sat-ordinals#get-token-by-outpoint 30 | func (c *Client) GetOneSatOrdinalByOutpoint(ctx context.Context, outpoint string) (*OneSatOrdinalToken, error) { 31 | if c.Chain() != ChainBSV { 32 | return nil, ErrBSVChainRequired 33 | } 34 | 35 | url := c.buildURL("/token/1satordinals/%s", outpoint) 36 | return requestAndUnmarshal[OneSatOrdinalToken](ctx, c, url, http.MethodGet, nil, nil) 37 | } 38 | 39 | // GetOneSatOrdinalContent gets content data for a 1Sat Ordinal token (BSV-only endpoint) 40 | // 41 | // For more information: https://docs.whatsonchain.com/api/tokens/1sat-ordinals#get-token-content 42 | func (c *Client) GetOneSatOrdinalContent(ctx context.Context, outpoint string) (*OneSatOrdinalContent, error) { 43 | if c.Chain() != ChainBSV { 44 | return nil, ErrBSVChainRequired 45 | } 46 | 47 | url := c.buildURL("/token/1satordinals/%s/content", outpoint) 48 | return requestAndUnmarshal[OneSatOrdinalContent](ctx, c, url, http.MethodGet, nil, nil) 49 | } 50 | 51 | // GetOneSatOrdinalLatest gets the latest transfer of a 1Sat Ordinal token (BSV-only endpoint) 52 | // 53 | // For more information: https://docs.whatsonchain.com/api/tokens/1sat-ordinals#get-token-latest-transfer 54 | func (c *Client) GetOneSatOrdinalLatest(ctx context.Context, outpoint string) (*OneSatOrdinalLatest, error) { 55 | if c.Chain() != ChainBSV { 56 | return nil, ErrBSVChainRequired 57 | } 58 | 59 | url := c.buildURL("/token/1satordinals/%s/latest", outpoint) 60 | return requestAndUnmarshal[OneSatOrdinalLatest](ctx, c, url, http.MethodGet, nil, nil) 61 | } 62 | 63 | // GetOneSatOrdinalHistory gets transfer history of a 1Sat Ordinal token (BSV-only endpoint) 64 | // 65 | // For more information: https://docs.whatsonchain.com/api/tokens/1sat-ordinals#get-token-transfers-history 66 | func (c *Client) GetOneSatOrdinalHistory(ctx context.Context, outpoint string) ([]*OneSatOrdinalHistory, error) { 67 | if c.Chain() != ChainBSV { 68 | return nil, ErrBSVChainRequired 69 | } 70 | 71 | url := c.buildURL("/token/1satordinals/%s/history", outpoint) 72 | return requestAndUnmarshalSlice[*OneSatOrdinalHistory](ctx, c, url, http.MethodGet, nil, nil) 73 | } 74 | 75 | // GetOneSatOrdinalsByTxID gets all 1Sat Ordinal tokens in a transaction (BSV-only endpoint) 76 | // 77 | // For more information: https://docs.whatsonchain.com/api/tokens/1sat-ordinals#get-tokens-by-txid 78 | func (c *Client) GetOneSatOrdinalsByTxID(ctx context.Context, txid string) ([]*OneSatOrdinalToken, error) { 79 | if c.Chain() != ChainBSV { 80 | return nil, ErrBSVChainRequired 81 | } 82 | 83 | url := c.buildURL("/token/1satordinals/tx/%s", txid) 84 | return requestAndUnmarshalSlice[*OneSatOrdinalToken](ctx, c, url, http.MethodGet, nil, nil) 85 | } 86 | 87 | // GetOneSatOrdinalsStats gets statistics for 1Sat Ordinals (BSV-only endpoint) 88 | // 89 | // For more information: https://docs.whatsonchain.com/api/tokens/1sat-ordinals#get-stats 90 | func (c *Client) GetOneSatOrdinalsStats(ctx context.Context) (*OneSatOrdinalStats, error) { 91 | if c.Chain() != ChainBSV { 92 | return nil, ErrBSVChainRequired 93 | } 94 | 95 | url := c.buildURL("/tokens/1satordinals") 96 | return requestAndUnmarshal[OneSatOrdinalStats](ctx, c, url, http.MethodGet, nil, nil) 97 | } 98 | 99 | // GetAllSTASTokens gets all STAS tokens (BSV-only endpoint) 100 | // 101 | // For more information: https://docs.whatsonchain.com/api/tokens/stas#get-all-tokens 102 | func (c *Client) GetAllSTASTokens(ctx context.Context) ([]*STASToken, error) { 103 | if c.Chain() != ChainBSV { 104 | return nil, ErrBSVChainRequired 105 | } 106 | 107 | url := c.buildURL("/tokens") 108 | return requestAndUnmarshalSlice[*STASToken](ctx, c, url, http.MethodGet, nil, nil) 109 | } 110 | 111 | // GetSTASTokenByID gets a STAS token by contract ID and symbol (BSV-only endpoint) 112 | // 113 | // For more information: https://docs.whatsonchain.com/api/tokens/stas#get-token-by-id 114 | func (c *Client) GetSTASTokenByID(ctx context.Context, contractID, symbol string) (*STASToken, error) { 115 | if c.Chain() != ChainBSV { 116 | return nil, ErrBSVChainRequired 117 | } 118 | 119 | url := c.buildURL("/token/%s/%s", contractID, symbol) 120 | return requestAndUnmarshal[STASToken](ctx, c, url, http.MethodGet, nil, nil) 121 | } 122 | 123 | // GetTokenUTXOsForAddress gets token UTXOs for an address (BSV-only endpoint) 124 | // 125 | // For more information: https://docs.whatsonchain.com/api/tokens/stas#get-token-utxos-for-address 126 | func (c *Client) GetTokenUTXOsForAddress(ctx context.Context, address string) ([]*STASTokenUTXO, error) { 127 | if c.Chain() != ChainBSV { 128 | return nil, ErrBSVChainRequired 129 | } 130 | 131 | url := c.buildURL("/address/%s/tokens/unspent", address) 132 | return requestAndUnmarshalSlice[*STASTokenUTXO](ctx, c, url, http.MethodGet, nil, nil) 133 | } 134 | 135 | // GetAddressTokenBalance gets token balance for an address (BSV-only endpoint) 136 | // 137 | // For more information: https://docs.whatsonchain.com/api/tokens/stas#get-address-token-balance 138 | func (c *Client) GetAddressTokenBalance(ctx context.Context, address string) (*STASTokenBalance, error) { 139 | if c.Chain() != ChainBSV { 140 | return nil, ErrBSVChainRequired 141 | } 142 | 143 | url := c.buildURL("/address/%s/tokens", address) 144 | return requestAndUnmarshal[STASTokenBalance](ctx, c, url, http.MethodGet, nil, nil) 145 | } 146 | 147 | // GetTokenTransactions gets transactions for a STAS token (BSV-only endpoint) 148 | // 149 | // For more information: https://docs.whatsonchain.com/api/tokens/stas#get-token-transactions 150 | func (c *Client) GetTokenTransactions(ctx context.Context, contractID, symbol string) (TxList, error) { 151 | if c.Chain() != ChainBSV { 152 | return nil, ErrBSVChainRequired 153 | } 154 | 155 | url := c.buildURL("/token/%s/%s/tx", contractID, symbol) 156 | return requestAndUnmarshalSlice[*TxInfo](ctx, c, url, http.MethodGet, nil, nil) 157 | } 158 | 159 | // GetSTASStats gets statistics for STAS tokens (BSV-only endpoint) 160 | // 161 | // For more information: https://docs.whatsonchain.com/api/tokens/stas#get-stats 162 | func (c *Client) GetSTASStats(ctx context.Context) (*STASStats, error) { 163 | if c.Chain() != ChainBSV { 164 | return nil, ErrBSVChainRequired 165 | } 166 | 167 | url := c.buildURL("/tokens/stas") 168 | return requestAndUnmarshal[STASStats](ctx, c, url, http.MethodGet, nil, nil) 169 | } 170 | -------------------------------------------------------------------------------- /.golangci.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatters": { 3 | "enable": [ 4 | "gofmt", 5 | "gofumpt" 6 | ], 7 | "exclusions": { 8 | "generated": "lax", 9 | "paths": [ 10 | ".*\\.my\\.go$", 11 | "lib/bad.go", 12 | ".make", 13 | ".vscode", 14 | "dist", 15 | "third_party$", 16 | "builtin$" 17 | ] 18 | }, 19 | "settings": { 20 | "gci": { 21 | "sections": [ 22 | "standard", 23 | "default", 24 | "prefix(github.com/mrz1836/go-whatsonchain)" 25 | ] 26 | }, 27 | "gofmt": { 28 | "simplify": true 29 | }, 30 | "gofumpt": { 31 | "extra-rules": false 32 | }, 33 | "goimports": { 34 | "local-prefixes": [ 35 | "github.com/mrz1836/go-whatsonchain" 36 | ] 37 | } 38 | } 39 | }, 40 | "issues": { 41 | "uniq-by-line": true 42 | }, 43 | "linters": { 44 | "disable": [ 45 | "gocritic", 46 | "godot", 47 | "gocognit", 48 | "nestif", 49 | "gocyclo", 50 | "wsl_v5" 51 | ], 52 | "enable": [ 53 | "arangolint", 54 | "asasalint", 55 | "asciicheck", 56 | "bidichk", 57 | "bodyclose", 58 | "containedctx", 59 | "contextcheck", 60 | "copyloopvar", 61 | "dogsled", 62 | "durationcheck", 63 | "embeddedstructfieldcheck", 64 | "err113", 65 | "errcheck", 66 | "errchkjson", 67 | "errname", 68 | "errorlint", 69 | "exhaustive", 70 | "forbidigo", 71 | "funcorder", 72 | "gocheckcompilerdirectives", 73 | "gochecknoglobals", 74 | "gochecknoinits", 75 | "gochecksumtype", 76 | "goconst", 77 | "godox", 78 | "goheader", 79 | "gomoddirectives", 80 | "gosec", 81 | "gosmopolitan", 82 | "govet", 83 | "inamedparam", 84 | "ineffassign", 85 | "loggercheck", 86 | "makezero", 87 | "mirror", 88 | "misspell", 89 | "musttag", 90 | "nakedret", 91 | "nilerr", 92 | "nilnesserr", 93 | "nilnil", 94 | "noctx", 95 | "nolintlint", 96 | "nosprintfhostport", 97 | "prealloc", 98 | "predeclared", 99 | "protogetter", 100 | "reassign", 101 | "recvcheck", 102 | "revive", 103 | "rowserrcheck", 104 | "spancheck", 105 | "sqlclosecheck", 106 | "staticcheck", 107 | "testifylint", 108 | "unconvert", 109 | "unparam", 110 | "unused", 111 | "wastedassign", 112 | "zerologlint" 113 | ], 114 | "settings": { 115 | "dogsled": { 116 | "max-blank-identifiers": 2 117 | }, 118 | "dupl": { 119 | "threshold": 100 120 | }, 121 | "exhaustive": { 122 | "default-signifies-exhaustive": false 123 | }, 124 | "funcorder": { 125 | "constructor-after-struct": true 126 | }, 127 | "funlen": { 128 | "lines": 60, 129 | "statements": 40 130 | }, 131 | "gocognit": { 132 | "min-complexity": 20 133 | }, 134 | "goconst": { 135 | "min-len": 3, 136 | "min-occurrences": 10 137 | }, 138 | "gocyclo": { 139 | "min-complexity": 10 140 | }, 141 | "godox": { 142 | "keywords": [ 143 | "NOTE", 144 | "OPTIMIZE", 145 | "HACK", 146 | "ATTN", 147 | "ATTENTION" 148 | ] 149 | }, 150 | "govet": { 151 | "enable": [ 152 | "atomicalign", 153 | "shadow" 154 | ], 155 | "settings": { 156 | "printf": { 157 | "funcs": [ 158 | "(github.com/golangci/golangci-lint/pkg/logutils.Log).Infof", 159 | "(github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf", 160 | "(github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf", 161 | "(github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf" 162 | ] 163 | } 164 | } 165 | }, 166 | "lll": { 167 | "line-length": 120, 168 | "tab-width": 1 169 | }, 170 | "misspell": { 171 | "ignore-rules": [ 172 | "bsv", 173 | "bitcoin" 174 | ], 175 | "locale": "US" 176 | }, 177 | "nakedret": { 178 | "max-func-lines": 30 179 | }, 180 | "nestif": { 181 | "min-complexity": 4 182 | }, 183 | "nolintlint": { 184 | "allow-unused": false, 185 | "require-explanation": true, 186 | "require-specific": true 187 | }, 188 | "prealloc": { 189 | "for-loops": false, 190 | "range-loops": true, 191 | "simple": true 192 | }, 193 | "revive": { 194 | "config": ".revive.toml" 195 | }, 196 | "unparam": { 197 | "check-exported": false 198 | }, 199 | "wsl": { 200 | "allow-assign-and-call": true, 201 | "allow-cuddle-declarations": true, 202 | "allow-multiline-assign": true, 203 | "strict-append": true 204 | } 205 | } 206 | }, 207 | "output": { 208 | "formats": { 209 | "text": { 210 | "path": "stdout", 211 | "print-issued-lines": true, 212 | "print-linter-name": true 213 | } 214 | } 215 | }, 216 | "run": { 217 | "allow-parallel-runners": true, 218 | "build-tags": [ 219 | "mage" 220 | ], 221 | "concurrency": 8, 222 | "issues-exit-code": 1, 223 | "max-issues-per-linter": 0, 224 | "max-same-issues": 0, 225 | "tests": true 226 | }, 227 | "severity": { 228 | "default": "warning", 229 | "rules": [ 230 | { 231 | "linters": [ 232 | "dupl", 233 | "misspell", 234 | "makezero" 235 | ], 236 | "severity": "info" 237 | } 238 | ] 239 | }, 240 | "version": "2" 241 | } 242 | -------------------------------------------------------------------------------- /scripts.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | // GetScriptHistory this endpoint retrieves confirmed and unconfirmed script transactions 11 | // 12 | // For more information: https://docs.whatsonchain.com/#get-script-history 13 | func (c *Client) GetScriptHistory(ctx context.Context, scriptHash string) (ScriptList, error) { 14 | url := c.buildURL("/script/%s/history", scriptHash) 15 | return requestAndUnmarshalSlice[*ScriptRecord](ctx, c, url, http.MethodGet, nil, ErrScriptNotFound) 16 | } 17 | 18 | // GetScriptUnspentTransactions this endpoint retrieves ordered list of UTXOs 19 | // 20 | // For more information: https://docs.whatsonchain.com/#get-script-unspent-transactions 21 | func (c *Client) GetScriptUnspentTransactions(ctx context.Context, scriptHash string) (ScriptList, error) { 22 | url := c.buildURL("/script/%s/unspent/all", scriptHash) 23 | return requestAndUnmarshalSlice[*ScriptRecord](ctx, c, url, http.MethodGet, nil, ErrScriptNotFound) 24 | } 25 | 26 | // BulkScriptUnspentTransactions will fetch UTXOs for multiple scripts in a single request 27 | // Max of 20 scripts at a time 28 | // 29 | // For more information: https://docs.whatsonchain.com/#bulk-script-unspent-transactions 30 | func (c *Client) BulkScriptUnspentTransactions(ctx context.Context, list *ScriptsList) (BulkScriptUnspentResponse, error) { 31 | if len(list.Scripts) > MaxScriptsForLookup { 32 | return nil, fmt.Errorf("%w: %d scripts requested, max is %d", ErrMaxScriptsExceeded, len(list.Scripts), MaxScriptsForLookup) 33 | } 34 | 35 | postData, err := json.Marshal(list) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | url := c.buildURL("/scripts/unspent/all") 41 | return requestAndUnmarshalSlice[*BulkScriptResponseRecord](ctx, c, url, http.MethodPost, postData, ErrScriptNotFound) 42 | } 43 | 44 | // ScriptUnconfirmedUTXOs retrieves unconfirmed UTXOs for a script 45 | // 46 | // For more information: https://docs.whatsonchain.com/#get-unconfirmed-script-utxos 47 | func (c *Client) ScriptUnconfirmedUTXOs(ctx context.Context, scriptHash string) (ScriptList, error) { 48 | url := c.buildURL("/script/%s/unconfirmed/unspent", scriptHash) 49 | return requestAndUnmarshalSlice[*ScriptRecord](ctx, c, url, http.MethodGet, nil, ErrScriptNotFound) 50 | } 51 | 52 | // BulkScriptUnconfirmedUTXOs retrieves unconfirmed UTXOs for multiple scripts 53 | // Max of 20 scripts at a time 54 | // 55 | // For more information: https://docs.whatsonchain.com/#bulk-unconfirmed-script-utxos 56 | func (c *Client) BulkScriptUnconfirmedUTXOs(ctx context.Context, list *ScriptsList) (BulkScriptUnspentResponse, error) { 57 | if len(list.Scripts) > MaxScriptsForLookup { 58 | return nil, fmt.Errorf("%w: %d scripts requested, max is %d", ErrMaxScriptsExceeded, len(list.Scripts), MaxScriptsForLookup) 59 | } 60 | 61 | postData, err := json.Marshal(list) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | url := c.buildURL("/scripts/unconfirmed/unspent") 67 | return requestAndUnmarshalSlice[*BulkScriptResponseRecord](ctx, c, url, http.MethodPost, postData, ErrScriptNotFound) 68 | } 69 | 70 | // ScriptConfirmedUTXOs retrieves confirmed UTXOs for a script 71 | // 72 | // For more information: https://docs.whatsonchain.com/#get-confirmed-script-utxos 73 | func (c *Client) ScriptConfirmedUTXOs(ctx context.Context, scriptHash string) (ScriptList, error) { 74 | url := c.buildURL("/script/%s/confirmed/unspent", scriptHash) 75 | return requestAndUnmarshalSlice[*ScriptRecord](ctx, c, url, http.MethodGet, nil, ErrScriptNotFound) 76 | } 77 | 78 | // BulkScriptConfirmedUTXOs retrieves confirmed UTXOs for multiple scripts 79 | // Max of 20 scripts at a time 80 | // 81 | // For more information: https://docs.whatsonchain.com/#bulk-confirmed-script-utxos 82 | func (c *Client) BulkScriptConfirmedUTXOs(ctx context.Context, list *ScriptsList) (BulkScriptUnspentResponse, error) { 83 | if len(list.Scripts) > MaxScriptsForLookup { 84 | return nil, fmt.Errorf("%w: %d scripts requested, max is %d", ErrMaxScriptsExceeded, len(list.Scripts), MaxScriptsForLookup) 85 | } 86 | 87 | postData, err := json.Marshal(list) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | url := c.buildURL("/scripts/confirmed/unspent") 93 | return requestAndUnmarshalSlice[*BulkScriptResponseRecord](ctx, c, url, http.MethodPost, postData, ErrScriptNotFound) 94 | } 95 | 96 | // GetScriptUsed this endpoint determines if a script has been used in any transaction 97 | // 98 | // For more information: https://docs.whatsonchain.com/api/script#get-script-usage 99 | func (c *Client) GetScriptUsed(ctx context.Context, scriptHash string) (bool, error) { 100 | url := c.buildURL("/script/%s/used", scriptHash) 101 | resp, err := requestString(ctx, c, url) 102 | if err != nil { 103 | return false, err 104 | } 105 | if len(resp) == 0 { 106 | return false, ErrScriptNotFound 107 | } 108 | // The response is a simple boolean string "true" or "false" 109 | return resp == "true", nil 110 | } 111 | 112 | // GetScriptUnconfirmedHistory this endpoint retrieves unconfirmed script transactions 113 | // 114 | // For more information: https://docs.whatsonchain.com/api/script#get-unconfirmed-script-history 115 | func (c *Client) GetScriptUnconfirmedHistory(ctx context.Context, scriptHash string) (ScriptList, error) { 116 | url := c.buildURL("/script/%s/unconfirmed/history", scriptHash) 117 | return requestAndUnmarshalSlice[*ScriptRecord](ctx, c, url, http.MethodGet, nil, ErrScriptNotFound) 118 | } 119 | 120 | // BulkScriptUnconfirmedHistory will fetch unconfirmed history for multiple scripts in a single request 121 | // Max of 20 scripts at a time 122 | // 123 | // For more information: https://docs.whatsonchain.com/api/script#bulk-unconfirmed-script-history 124 | func (c *Client) BulkScriptUnconfirmedHistory(ctx context.Context, list *ScriptsList) (BulkScriptHistoryResponse, error) { 125 | if len(list.Scripts) > MaxScriptsForLookup { 126 | return nil, fmt.Errorf("%w: %d scripts requested, max is %d", ErrMaxScriptsExceeded, len(list.Scripts), MaxScriptsForLookup) 127 | } 128 | 129 | postData, err := json.Marshal(list) 130 | if err != nil { 131 | return nil, err 132 | } 133 | 134 | url := c.buildURL("/scripts/unconfirmed/history") 135 | return requestAndUnmarshalSlice[*BulkScriptHistoryRecord](ctx, c, url, http.MethodPost, postData, ErrScriptNotFound) 136 | } 137 | 138 | // GetScriptConfirmedHistory this endpoint retrieves confirmed script transactions 139 | // 140 | // For more information: https://docs.whatsonchain.com/api/script#get-confirmed-script-history 141 | func (c *Client) GetScriptConfirmedHistory(ctx context.Context, scriptHash string) (ScriptList, error) { 142 | url := c.buildURL("/script/%s/confirmed/history", scriptHash) 143 | return requestAndUnmarshalSlice[*ScriptRecord](ctx, c, url, http.MethodGet, nil, ErrScriptNotFound) 144 | } 145 | 146 | // BulkScriptConfirmedHistory will fetch confirmed history for multiple scripts in a single request 147 | // Max of 20 scripts at a time 148 | // 149 | // For more information: https://docs.whatsonchain.com/api/script#bulk-confirmed-script-history 150 | func (c *Client) BulkScriptConfirmedHistory(ctx context.Context, list *ScriptsList) (BulkScriptHistoryResponse, error) { 151 | if len(list.Scripts) > MaxScriptsForLookup { 152 | return nil, fmt.Errorf("%w: %d scripts requested, max is %d", ErrMaxScriptsExceeded, len(list.Scripts), MaxScriptsForLookup) 153 | } 154 | 155 | postData, err := json.Marshal(list) 156 | if err != nil { 157 | return nil, err 158 | } 159 | 160 | url := c.buildURL("/scripts/confirmed/history") 161 | return requestAndUnmarshalSlice[*BulkScriptHistoryRecord](ctx, c, url, http.MethodPost, postData, ErrScriptNotFound) 162 | } 163 | -------------------------------------------------------------------------------- /chain_info_benchmark_test.go: -------------------------------------------------------------------------------- 1 | package whatsonchain 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | // mockHTTPChainInfoBenchmark provides mock HTTP responses for chain info benchmarks 12 | type mockHTTPChainInfoBenchmark struct{} 13 | 14 | func (m *mockHTTPChainInfoBenchmark) Do(req *http.Request) (*http.Response, error) { 15 | resp := new(http.Response) 16 | resp.StatusCode = http.StatusOK 17 | 18 | // Chain info 19 | if strings.Contains(req.URL.String(), "/chain/info") { 20 | resp.Body = io.NopCloser(strings.NewReader(`{"chain":"main","blocks":700000,"headers":700000,"bestblockhash":"00000000000000000373d17e4f4f8e0f0f3f3e3d3c3b3a39383736353433323","difficulty":1234567.89,"mediantime":1609456000,"verificationprogress":0.999999,"chainwork":"00000000000000000000000000000000000000000000000000000000000000ff","pruned":false}`)) 21 | return resp, nil 22 | } 23 | 24 | // Chain tips 25 | if strings.Contains(req.URL.String(), "/chain/tips") { 26 | resp.Body = io.NopCloser(strings.NewReader(`[{"height":700000,"hash":"00000000000000000373d17e4f4f8e0f0f3f3e3d3c3b3a39383736353433323","branchlen":0,"status":"active"}]`)) 27 | return resp, nil 28 | } 29 | 30 | // Circulating supply 31 | if strings.Contains(req.URL.String(), "/circulatingsupply") { 32 | resp.Body = io.NopCloser(strings.NewReader(`18750000.00000000`)) 33 | return resp, nil 34 | } 35 | 36 | // Exchange rate 37 | if strings.Contains(req.URL.String(), "/exchangerate") && !strings.Contains(req.URL.String(), "/historical") { 38 | resp.Body = io.NopCloser(strings.NewReader(`{"rate":50000.50,"currency":"USD","time":1609459200}`)) 39 | return resp, nil 40 | } 41 | 42 | // Historical exchange rate 43 | if strings.Contains(req.URL.String(), "/historical") { 44 | resp.Body = io.NopCloser(strings.NewReader(`[{"rate":50000.50,"currency":"USD","time":1609459200},{"rate":49500.25,"currency":"USD","time":1609459100}]`)) 45 | return resp, nil 46 | } 47 | 48 | // Peer info 49 | if strings.Contains(req.URL.String(), "/peer/info") { 50 | resp.Body = io.NopCloser(strings.NewReader(`[{"id":1,"addr":"192.168.1.1:8333","addrlocal":"192.168.1.100:54321","services":"000000000000040d","relaytxes":true,"lastsend":1609459200,"lastrecv":1609459200,"bytessent":123456,"bytesrecv":654321,"conntime":1609450000,"timeoffset":0,"pingtime":0.05,"minping":0.03,"version":70015,"subver":"/Bitcoin SV:1.0.0/","inbound":false,"addnode":false,"startingheight":700000,"txninvsize":0,"banscore":0,"synced_headers":700000,"synced_blocks":700000,"whitelisted":false}]`)) 51 | return resp, nil 52 | } 53 | 54 | // Mempool info - mempoolminfee should be int64 (satoshis) 55 | if strings.Contains(req.URL.String(), "/mempool/info") { 56 | resp.Body = io.NopCloser(strings.NewReader(`{"size":5000,"bytes":2500000,"usage":3000000,"maxmempool":300000000,"mempoolminfee":1000}`)) 57 | return resp, nil 58 | } 59 | 60 | // Mempool transactions 61 | if strings.Contains(req.URL.String(), "/mempool/raw") { 62 | resp.Body = io.NopCloser(strings.NewReader(`["tx1","tx2","tx3"]`)) 63 | return resp, nil 64 | } 65 | 66 | resp.Body = io.NopCloser(strings.NewReader(`{}`)) 67 | return resp, nil 68 | } 69 | 70 | // BenchmarkGetChainInfo benchmarks getting chain information 71 | func BenchmarkGetChainInfo(b *testing.B) { 72 | client, _ := NewClient(context.Background(), 73 | WithChain(ChainBSV), 74 | WithNetwork(NetworkMain), 75 | WithHTTPClient(&mockHTTPChainInfoBenchmark{}), 76 | ) 77 | 78 | ctx := context.Background() 79 | 80 | b.ResetTimer() 81 | b.ReportAllocs() 82 | for i := 0; i < b.N; i++ { 83 | _, err := client.GetChainInfo(ctx) 84 | if err != nil { 85 | b.Fatal(err) 86 | } 87 | } 88 | } 89 | 90 | // BenchmarkGetChainTips benchmarks getting chain tips 91 | func BenchmarkGetChainTips(b *testing.B) { 92 | client, _ := NewClient(context.Background(), 93 | WithChain(ChainBSV), 94 | WithNetwork(NetworkMain), 95 | WithHTTPClient(&mockHTTPChainInfoBenchmark{}), 96 | ) 97 | 98 | ctx := context.Background() 99 | 100 | b.ResetTimer() 101 | b.ReportAllocs() 102 | for i := 0; i < b.N; i++ { 103 | _, err := client.GetChainTips(ctx) 104 | if err != nil { 105 | b.Fatal(err) 106 | } 107 | } 108 | } 109 | 110 | // BenchmarkGetCirculatingSupply benchmarks getting circulating supply 111 | func BenchmarkGetCirculatingSupply(b *testing.B) { 112 | client, _ := NewClient(context.Background(), 113 | WithChain(ChainBSV), 114 | WithNetwork(NetworkMain), 115 | WithHTTPClient(&mockHTTPChainInfoBenchmark{}), 116 | ) 117 | 118 | ctx := context.Background() 119 | 120 | b.ResetTimer() 121 | b.ReportAllocs() 122 | for i := 0; i < b.N; i++ { 123 | _, err := client.GetCirculatingSupply(ctx) 124 | if err != nil { 125 | b.Fatal(err) 126 | } 127 | } 128 | } 129 | 130 | // BenchmarkGetExchangeRate benchmarks getting current exchange rate 131 | func BenchmarkGetExchangeRate(b *testing.B) { 132 | client, _ := NewClient(context.Background(), 133 | WithChain(ChainBSV), 134 | WithNetwork(NetworkMain), 135 | WithHTTPClient(&mockHTTPChainInfoBenchmark{}), 136 | ) 137 | 138 | ctx := context.Background() 139 | 140 | b.ResetTimer() 141 | b.ReportAllocs() 142 | for i := 0; i < b.N; i++ { 143 | _, err := client.GetExchangeRate(ctx) 144 | if err != nil { 145 | b.Fatal(err) 146 | } 147 | } 148 | } 149 | 150 | // BenchmarkGetHistoricalExchangeRate benchmarks getting historical exchange rates 151 | func BenchmarkGetHistoricalExchangeRate(b *testing.B) { 152 | client, _ := NewClient(context.Background(), 153 | WithChain(ChainBSV), 154 | WithNetwork(NetworkMain), 155 | WithHTTPClient(&mockHTTPChainInfoBenchmark{}), 156 | ) 157 | 158 | ctx := context.Background() 159 | 160 | tests := []struct { 161 | name string 162 | fromTime int64 163 | toTime int64 164 | }{ 165 | {"1Hour", 1609455600, 1609459200}, 166 | {"1Day", 1609372800, 1609459200}, 167 | {"1Week", 1608854400, 1609459200}, 168 | } 169 | 170 | for _, tt := range tests { 171 | b.Run(tt.name, func(b *testing.B) { 172 | b.ReportAllocs() 173 | for i := 0; i < b.N; i++ { 174 | _, err := client.GetHistoricalExchangeRate(ctx, tt.fromTime, tt.toTime) 175 | if err != nil { 176 | b.Fatal(err) 177 | } 178 | } 179 | }) 180 | } 181 | } 182 | 183 | // BenchmarkGetPeerInfo benchmarks getting peer information 184 | func BenchmarkGetPeerInfo(b *testing.B) { 185 | client, _ := NewClient(context.Background(), 186 | WithChain(ChainBSV), 187 | WithNetwork(NetworkMain), 188 | WithHTTPClient(&mockHTTPChainInfoBenchmark{}), 189 | ) 190 | 191 | ctx := context.Background() 192 | 193 | b.ResetTimer() 194 | b.ReportAllocs() 195 | for i := 0; i < b.N; i++ { 196 | _, err := client.GetPeerInfo(ctx) 197 | if err != nil { 198 | b.Fatal(err) 199 | } 200 | } 201 | } 202 | 203 | // BenchmarkGetMempoolInfo benchmarks getting mempool information 204 | func BenchmarkGetMempoolInfo(b *testing.B) { 205 | client, _ := NewClient(context.Background(), 206 | WithChain(ChainBSV), 207 | WithNetwork(NetworkMain), 208 | WithHTTPClient(&mockHTTPChainInfoBenchmark{}), 209 | ) 210 | 211 | ctx := context.Background() 212 | 213 | b.ResetTimer() 214 | b.ReportAllocs() 215 | for i := 0; i < b.N; i++ { 216 | _, err := client.GetMempoolInfo(ctx) 217 | if err != nil { 218 | b.Fatal(err) 219 | } 220 | } 221 | } 222 | 223 | // BenchmarkGetMempoolTransactions benchmarks getting mempool transactions 224 | func BenchmarkGetMempoolTransactions(b *testing.B) { 225 | client, _ := NewClient(context.Background(), 226 | WithChain(ChainBSV), 227 | WithNetwork(NetworkMain), 228 | WithHTTPClient(&mockHTTPChainInfoBenchmark{}), 229 | ) 230 | 231 | ctx := context.Background() 232 | 233 | b.ResetTimer() 234 | b.ReportAllocs() 235 | for i := 0; i < b.N; i++ { 236 | _, err := client.GetMempoolTransactions(ctx) 237 | if err != nil { 238 | b.Fatal(err) 239 | } 240 | } 241 | } 242 | --------------------------------------------------------------------------------