//go:build integration package api import ( "encoding/json" "fmt" "net/http" "net/url" "sort" "strings" "testing" ) func (h *TestHandler) getStatus(t *testing.T) *statusBlockbook { if h.status != nil { return h.status } var envelope statusEnvelope h.mustGetJSON(t, "/api/status", &envelope) if !hasNonEmptyObject(envelope.Blockbook) { t.Fatalf("status response missing non-empty blockbook object") } if !hasNonEmptyObject(envelope.Backend) { t.Fatalf("status response missing non-empty backend object") } var bb statusBlockbook if err := json.Unmarshal(envelope.Blockbook, &bb); err != nil { t.Fatalf("decode status blockbook object: %v", err) } if bb.BestHeight <= 0 { t.Fatalf("invalid status bestHeight: %d", bb.BestHeight) } h.status = &bb return h.status } func (h *TestHandler) findTransactionNearHeight(t *testing.T, fromHeight, window int) (txid string, height int, hash string, found bool) { lower := fromHeight - window if lower < 0 { lower = 0 } for height = fromHeight; height >= lower; height-- { hash, ok := h.getBlockHashForHeight(t, height, false) if !ok { continue } blk, ok := h.getBlockByHashForSampling(t, hash, false) if !ok { continue } if len(blk.TxIDs) == 0 { continue } txid = strings.TrimSpace(blk.TxIDs[0]) if txid == "" { continue } return txid, height, hash, true } return "", 0, "", false } func (h *TestHandler) getSampleTxID(t *testing.T) (string, bool) { if h.sampleTxResolved { return h.sampleTxID, h.sampleTxID != "" } status := h.getStatus(t) txid, _, _, found := h.findTransactionNearHeight(t, status.BestHeight, txSearchWindow) h.sampleTxResolved = true if !found { return "", false } h.sampleTxID = txid return h.sampleTxID, true } func (h *TestHandler) getSampleAddress(t *testing.T) (string, bool) { if h.sampleAddrResolved { return h.sampleAddress, h.sampleAddress != "" } txid, found := h.getSampleTxID(t) h.sampleAddrResolved = true if !found { return "", false } tx, ok := h.getTransactionByID(t, txid, false) if !ok { return "", false } if isEVMTxID(txid) { h.sampleAddress = firstAddressFromTxPreferVin(tx) } else { h.sampleAddress = firstAddressFromTx(tx) } return h.sampleAddress, h.sampleAddress != "" } func (h *TestHandler) getSampleIndexedBlock(t *testing.T) (height int, hash string, found bool) { if h.sampleBlockResolved { return h.sampleBlockHeight, h.sampleBlockHash, h.sampleBlockHash != "" } status := h.getStatus(t) start := status.BestHeight if start > 2 { start -= 2 } lower := start - txSearchWindow if lower < 1 { lower = 1 } h.sampleBlockResolved = true for height = start; height >= lower; height-- { hash, ok := h.getBlockHashForHeight(t, height, false) if !ok || strings.TrimSpace(hash) == "" { continue } // Some backends can briefly expose block-index without serving the block body yet. path := fmt.Sprintf("/api/v2/block/%d?page=1&pageSize=%d", height, blockPageSize) statusCode, _ := h.getHTTP(t, path) if statusCode != http.StatusOK { continue } h.sampleBlockHeight = height h.sampleBlockHash = hash return height, hash, true } return 0, "", false } func (h *TestHandler) getSampleIndexedHeight(t *testing.T) (height int, hash string, found bool) { if h.sampleIndexResolved { return h.sampleIndexHeight, h.sampleIndexHash, h.sampleIndexHash != "" } // If block-ready sample is already known, reuse it. if h.sampleBlockResolved && h.sampleBlockHash != "" { return h.sampleBlockHeight, h.sampleBlockHash, true } status := h.getStatus(t) start := status.BestHeight if start > 2 { start -= 2 } lower := start - txSearchWindow if lower < 1 { lower = 1 } h.sampleIndexResolved = true for height = start; height >= lower; height-- { hash, ok := h.getBlockHashForHeight(t, height, false) if !ok || strings.TrimSpace(hash) == "" { continue } h.sampleIndexHeight = height h.sampleIndexHash = hash return height, hash, true } return 0, "", false } func firstAddressFromTx(tx *txDetailResponse) string { for i := range tx.Vout { for _, addr := range tx.Vout[i].Addresses { if isAddressCandidate(addr) { return addr } } } for i := range tx.Vin { for _, addr := range tx.Vin[i].Addresses { if isAddressCandidate(addr) { return addr } } } return "" } func firstAddressFromTxPreferVin(tx *txDetailResponse) string { for i := range tx.Vin { for _, addr := range tx.Vin[i].Addresses { if isAddressCandidate(addr) { return addr } } } for i := range tx.Vout { for _, addr := range tx.Vout[i].Addresses { if isAddressCandidate(addr) { return addr } } } return "" } func isAddressCandidate(addr string) bool { addr = strings.TrimSpace(addr) if addr == "" { return false } upper := strings.ToUpper(addr) if strings.HasPrefix(upper, "OP_RETURN") { return false } return !strings.ContainsAny(addr, " \t\r\n") } func (h *TestHandler) getTransactionByID(t *testing.T, txid string, strict bool) (*txDetailResponse, bool) { if tx, found := h.txByID[txid]; found { return tx, true } path := "/api/v2/tx/" + url.PathEscape(txid) status, body := h.getHTTP(t, path) if status != http.StatusOK { if strict { t.Fatalf("GET %s returned HTTP %d: %s", path, status, preview(body)) } return nil, false } var tx txDetailResponse if err := json.Unmarshal(body, &tx); err != nil { t.Fatalf("decode transaction response for %s: %v", txid, err) } if tx.Txid == "" { if strict { t.Fatalf("empty txid in transaction response for %s", txid) } return nil, false } if tx.Txid != txid { if strict { t.Fatalf("transaction mismatch: got %s, want %s", tx.Txid, txid) } return nil, false } h.txByID[txid] = &tx return &tx, true } func (h *TestHandler) getBlockHashForHeight(t *testing.T, height int, strict bool) (string, bool) { if hash, found := h.blockHashByHeight[height]; found { return hash, true } path := fmt.Sprintf("/api/v2/block-index/%d", height) status, body := h.getHTTP(t, path) if status != http.StatusOK { if strict { t.Fatalf("GET %s returned HTTP %d: %s", path, status, preview(body)) } return "", false } var res blockIndexResponse if err := json.Unmarshal(body, &res); err != nil { t.Fatalf("decode block-index response at height %d: %v", height, err) } res.BlockHash = strings.TrimSpace(res.BlockHash) if res.BlockHash == "" { if strict { t.Fatalf("empty blockHash for height %d", height) } return "", false } h.blockHashByHeight[height] = res.BlockHash return res.BlockHash, true } func (h *TestHandler) getBlockByHash(t *testing.T, hash string, strict bool) (*blockSummary, bool) { if blk, found := h.blockByHash[hash]; found { return blk, true } path := fmt.Sprintf("/api/v2/block/%s?page=1&pageSize=%d", url.PathEscape(hash), blockPageSize) status, body := h.getHTTP(t, path) if status != http.StatusOK { if strict { t.Fatalf("GET %s returned HTTP %d: %s", path, status, preview(body)) } return nil, false } var res blockResponse if err := json.Unmarshal(body, &res); err != nil { t.Fatalf("decode block response for %s: %v", hash, err) } blk := &blockSummary{ Hash: strings.TrimSpace(res.Hash), Height: res.Height, HasTxField: res.Txs != nil, TxIDs: extractTxIDs(t, res.Txs), } if blk.Hash == "" { if strict { t.Fatalf("empty hash in block response for %s", hash) } return nil, false } h.blockByHash[hash] = blk return blk, true } func (h *TestHandler) getBlockByHashForSampling(t *testing.T, hash string, strict bool) (*blockSummary, bool) { if blk, found := h.blockByHash[hash]; found && len(blk.TxIDs) >= sampleBlockPageSize { return blk, true } path := fmt.Sprintf("/api/v2/block/%s?page=1&pageSize=%d", url.PathEscape(hash), sampleBlockPageSize) status, body := h.getHTTP(t, path) if status != http.StatusOK { if strict { t.Fatalf("GET %s returned HTTP %d: %s", path, status, preview(body)) } return nil, false } var res blockResponse if err := json.Unmarshal(body, &res); err != nil { t.Fatalf("decode block response for %s: %v", hash, err) } blk := &blockSummary{ Hash: strings.TrimSpace(res.Hash), Height: res.Height, HasTxField: res.Txs != nil, TxIDs: extractTxIDs(t, res.Txs), } if blk.Hash == "" { if strict { t.Fatalf("empty hash in block response for %s", hash) } return nil, false } h.blockByHash[hash] = blk return blk, true } func extractTxIDs(t *testing.T, txs []json.RawMessage) []string { t.Helper() if txs == nil { return nil } type candidate struct { txid string weight int } candidates := make([]candidate, 0, len(txs)) for i := range txs { raw := txs[i] var asString string if err := json.Unmarshal(raw, &asString); err == nil { asString = strings.TrimSpace(asString) if asString != "" { candidates = append(candidates, candidate{ txid: asString, weight: len(raw), }) } continue } var asObject struct { Txid string `json:"txid"` Hash string `json:"hash"` } if err := json.Unmarshal(raw, &asObject); err != nil { t.Fatalf("unexpected tx format at index %d: %v", i, err) } txid := strings.TrimSpace(asObject.Txid) if txid == "" { txid = strings.TrimSpace(asObject.Hash) } if txid != "" { // Smaller transaction payloads tend to produce faster /tx lookups. // Keep deterministic ordering by using the raw message size as a hint. candidates = append(candidates, candidate{ txid: txid, weight: len(raw), }) } } sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].weight < candidates[j].weight }) txids := make([]string, 0, len(candidates)) for i := range candidates { txids = append(txids, candidates[i].txid) } return txids } func hasNonEmptyObject(raw json.RawMessage) bool { v := strings.TrimSpace(string(raw)) return v != "" && v != "null" && v != "{}" } func (h *TestHandler) sampleTxIDOrSkip(t *testing.T) string { t.Helper() txid, found := h.getSampleTxID(t) if !found { t.Skipf("Skipping test, no transaction found in last %d blocks from height %d", txSearchWindow, h.getStatus(t).BestHeight) } return txid } func (h *TestHandler) sampleAddressOrSkip(t *testing.T) string { t.Helper() address, found := h.getSampleAddress(t) if !found { t.Skipf("Skipping test, no address found from recent transaction window at height %d", h.getStatus(t).BestHeight) } return address } func (h *TestHandler) requireCapabilities(t *testing.T, required testCapability, group, test string) bool { t.Helper() if required == capabilityNone { return true } h.resolveCapabilities(t) if required&capabilityUTXO != 0 && !h.supportsUTXO { reason := h.utxoProbeMessage if reason == "" { reason = "unsupported by endpoint" } t.Skipf("Skipping %s (%s): UTXO capability required (%s)", test, group, reason) return false } if required&capabilityEVM != 0 && !h.supportsEVM { reason := h.evmProbeMessage if reason == "" { reason = "unsupported by endpoint" } t.Skipf("Skipping %s (%s): EVM capability required (%s)", test, group, reason) return false } return true } func (h *TestHandler) resolveCapabilities(t *testing.T) { t.Helper() if h.capabilitiesResolved { return } h.capabilitiesResolved = true h.supportsUTXO, h.utxoProbeMessage = h.probeUTXOSupport(t) h.supportsEVM, h.evmProbeMessage = h.probeEVMSupport(t) } func (h *TestHandler) probeUTXOSupport(t *testing.T) (bool, string) { t.Helper() txid, found := h.getSampleTxID(t) if !found { return false, fmt.Sprintf("no sample transaction in last %d blocks", txSearchWindow) } if isEVMTxID(txid) { return false, "detected EVM-style transaction ids (0x prefix)" } address, found := h.getSampleAddress(t) if !found { return false, "no sample address available for probe" } path := "/api/v2/utxo/" + url.PathEscape(address) + "?confirmed=true" status, body := h.getHTTP(t, path) if status != http.StatusOK { t.Fatalf("UTXO capability probe %s returned HTTP %d: %s", path, status, preview(body)) } var utxos []utxoResponse if err := json.Unmarshal(body, &utxos); err != nil { t.Fatalf("decode UTXO capability probe %s: %v", path, err) } return true, "UTXO endpoint probe succeeded" } func (h *TestHandler) probeEVMSupport(t *testing.T) (bool, string) { t.Helper() txid, found := h.getSampleTxID(t) if !found { return false, fmt.Sprintf("no sample transaction in last %d blocks", txSearchWindow) } if !isEVMTxID(txid) { return false, "detected non-EVM transaction ids (missing 0x prefix)" } address, found := h.getSampleAddress(t) if !found { return false, "no sample address available for probe" } path := buildAddressDetailsPath(address, "tokenBalances", addressPage, addressPageSize) status, body := h.getHTTP(t, path) if status != http.StatusOK { t.Fatalf("EVM capability probe %s returned HTTP %d: %s", path, status, preview(body)) } var resp evmAddressTokenBalanceResponse if err := json.Unmarshal(body, &resp); err != nil { t.Fatalf("decode EVM capability probe %s: %v", path, err) } assertAddressMatches(t, resp.Address, address, "EVM capability probe address") return true, "EVM tokenBalances endpoint probe succeeded" } func isEVMTxID(txid string) bool { return strings.HasPrefix(strings.ToLower(strings.TrimSpace(txid)), "0x") } func isEVMAddress(address string) bool { return strings.HasPrefix(strings.ToLower(strings.TrimSpace(address)), "0x") } func (h *TestHandler) sampleEVMTxIDOrSkip(t *testing.T) string { t.Helper() txid := h.sampleTxIDOrSkip(t) if !isEVMTxID(txid) { t.Skipf("Skipping test, sample txid %s does not look EVM-like", txid) } return txid } func (h *TestHandler) sampleEVMAddressOrSkip(t *testing.T) string { t.Helper() address := h.sampleAddressOrSkip(t) if !isEVMAddress(address) { t.Skipf("Skipping test, sample address %s does not look EVM-like", address) } return address } func (h *TestHandler) getSampleEVMContract(t *testing.T) (string, bool) { if h.sampleContractResolved { return h.sampleContract, h.sampleContract != "" } address, found := h.getSampleAddress(t) h.sampleContractResolved = true if !found || !isEVMAddress(address) { return "", false } path := buildAddressDetailsPath(address, "tokenBalances", addressPage, addressPageSize) status, body := h.getHTTP(t, path) if status != http.StatusOK { t.Fatalf("GET %s returned HTTP %d: %s", path, status, preview(body)) } var resp evmAddressTokenBalanceResponse if err := json.Unmarshal(body, &resp); err != nil { t.Fatalf("decode tokenBalances for sample contract: %v", err) } assertAddressMatches(t, resp.Address, address, "sample EVM contract probe address") for i := range resp.Tokens { contract := strings.TrimSpace(resp.Tokens[i].Contract) if contract != "" { h.sampleContract = contract break } } return h.sampleContract, h.sampleContract != "" } func (h *TestHandler) sampleEVMContractOrSkip(t *testing.T) string { t.Helper() contract, found := h.getSampleEVMContract(t) if !found { t.Skipf("Skipping test, no contract found for sampled EVM address %s", h.sampleAddress) } return contract }