Skip to content

feat(asset/http): POST /api/v1/utxos bulk lookup + drop redundant tx fetch on GetUTXO#950

Merged
icellan merged 9 commits into
bsv-blockchain:mainfrom
icellan:feat/bulk-utxos
May 27, 2026
Merged

feat(asset/http): POST /api/v1/utxos bulk lookup + drop redundant tx fetch on GetUTXO#950
icellan merged 9 commits into
bsv-blockchain:mainfrom
icellan:feat/bulk-utxos

Conversation

@icellan

@icellan icellan commented May 26, 2026

Copy link
Copy Markdown
Contributor

Addresses #945 — adds a bulk UTXO spend-status endpoint and fixes the underlying slowness that made the single-UTXO endpoint not benefit from client-side parallelism.

Summary

  • stores/utxo/{aerospike,sql}.GetSpend: tolerate spend.UTXOHash == nil. The store already locates the record by primary key (txid, vout) and knows the canonical hash; the caller-supplied hash was a defensive check. Every existing caller still passes a hash, so the invariant is preserved for them — only the new HTTP fast paths opt out. The SQL path also stops dereferencing nil (was a latent panic).
  • services/asset/httpimpl/GetUTXO.go: stop calling GetTransaction + NewTxFromBytes + UTXOHashFromOutput. Each GET /api/v1/utxo/:hash?vout=N is now one Aerospike record read instead of (blob-fetch on cache miss) + (full tx parse) + (Aerospike Get). This is the latency the discussion was about.
  • services/asset/httpimpl/GetUTXOs.go (new): POST /api/v1/utxos. Binary request body of 36-byte (txid || voutLE) records; binary response of fixed 48-byte records (statusLE || lockTimeLE || vinLE || spendingTxID) in input order, zero-padded when unspent so clients can index by i*48. Fan-out via errgroup capped at 1024, preallocated response with per-slot writes (no mutex). Body size capped by a new asset_httpBodyLimit setting (default 4MB, ≈116k records).

Behaviour change to flag

The old single-endpoint distinguished "transaction not found" from "output index out of range" in the 404 message. Both now collapse to a uniform NOT_FOUND (3): UTXO not found. The HTTP status is unchanged. Clients keying off the message string need to adjust; clients keying off status code do not.

Test plan

  • go test -race ./services/asset/httpimpl/... (includes new TestGetUTXOs and updated TestGetUTXO)
  • go test -race ./stores/utxo/sql/... (includes new TestGetSpendNilUTXOHash; one pre-existing unrelated failure TestMinedThenSpendAllPrunes_* documented separately)
  • go test ./stores/utxo/aerospike/... (full integration suite, exercises the nil-tolerance change)
  • go vet, staticcheck, golangci-lint clean on touched packages
  • Reviewer to spot-check the binary record layout against client expectations (see record layout in GetUTXOs.go doc comment)

…fetch on GetUTXO

Addresses bsv-blockchain#945. The single-UTXO endpoint was fetching the full raw
transaction per request just to recompute a hash the store already had,
which dominated latency and made parallel client requests no faster than
sequential ones.

Three coordinated changes:

1. stores/utxo/{aerospike,sql}: make GetSpend tolerate spend.UTXOHash == nil.
   Both stores locate the record by primary key (txid, vout) and already
   know the canonical hash; the caller-supplied hash was a defensive check.
   Existing callers still pass a hash; only the new HTTP fast paths opt
   out. The SQL path also stops dereferencing nil.

2. services/asset/httpimpl/GetUTXO.go: stop calling GetTransaction +
   NewTxFromBytes + UTXOHashFromOutput. Each request is now one Aerospike
   record read instead of (blob-fetch + tx-parse + Aerospike Get). The
   "output index out of range" path collapses into the existing
   Status_NOT_FOUND branch; error message is now uniform "UTXO not found".

3. services/asset/httpimpl/GetUTXOs.go (new): POST /api/v1/utxos. Binary
   request body of 36-byte (32B txid + 4B vout LE) records, binary
   response of fixed 48-byte records (status + lockTime + spendingVin +
   spendingTxID) in input order, zero-padded when unspent so clients can
   index by i*48. Fan-out via errgroup with SafeSetLimit(1024), preallocated
   response with per-slot writes (no mutex). Body size capped by a new
   asset_httpBodyLimit setting (default 4MB, ~116k records).

Tests:
- new TestGetSpendNilUTXOHash (SQL): nil skips the check, wrong non-nil still fails.
- TestGetUTXO updated for the new path; new "Does not call GetTransaction" regression subtest.
- new TestGetUTXOs: empty body, malformed length, mixed found/unspent/spent/not-found
  with order preservation, UTXOHash nil assertion, per-record vs whole-request error policy.
@github-actions

github-actions Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Current Review:
No issues found. All previously identified concerns have been addressed:

  • Nil UTXOHash handling: Fixed in repository.go:920 - now safely logs TxID:Vout instead of dereferencing UTXOHash
  • Documentation accuracy: swagger_routes.go:384 correctly states "default 100MB" matching asset_reference.md:788
  • Test coverage: Added TestGetUtxoNilUTXOHash to verify nil-hash path works with real Repository

Summary:
This PR successfully optimizes UTXO lookups by:

  1. Removing redundant transaction fetches from GetUTXO (eliminates full tx parse per lookup)
  2. Adding bulk endpoint POST /api/v1/utxos with bounded concurrency (1024 goroutines)
  3. Making store implementations tolerant of nil UTXOHash when caller provides (txid, vout) primary key

The implementation is solid, well-tested, and follows the project verification workflow from AGENTS.md. Documentation accurately describes the new bulk endpoint behavior including the known lockTime semantic divergence between SQL and Aerospike backends.

@github-actions

Copy link
Copy Markdown
Contributor

[Minor] Error format mismatch in GetUTXOs.go line 142

The format string "error getting utxo %s:%d" has 2 placeholders but 3 arguments are provided (txHash.String(), vout, err). The error will be wrapped but not shown in the message.

Suggested fix:
errors.NewProcessingError("error getting utxo %s:%d: %v", txHash.String(), vout, err)

This matches the pattern used elsewhere in the codebase (see stores/utxo/aerospike/spend.go:194 for similar usage).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a bulk UTXO spend-status lookup endpoint to the Asset HTTP service and optimizes the existing single-UTXO endpoint by removing redundant transaction fetch/parse work, relying on the UTXO store to locate records by (txid, vout).

Changes:

  • Add POST /api/v1/utxos bulk binary lookup endpoint with bounded fan-out, fixed-size binary responses, and Prometheus tracking.
  • Speed up GET /api/v1/utxo/:hash?vout=N by skipping GetTransaction + tx parsing + recomputed UTXO hash.
  • Allow GetSpend to accept spend.UTXOHash == nil in both SQL and Aerospike stores; add a configurable asset_httpBodyLimit.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
stores/utxo/sql/sql.go Allow nil Spend.UTXOHash by skipping the mismatch check when absent.
stores/utxo/sql/sql_test.go Add coverage for nil-hash GetSpend behavior and mismatch behavior when non-nil.
stores/utxo/aerospike/get.go Allow nil Spend.UTXOHash by skipping the mismatch check when absent.
settings/settings.go Wire new asset_httpBodyLimit into constructed settings.
settings/asset_settings.go Add AssetSettings.HTTPBodyLimit metadata for the new setting.
services/asset/httpimpl/metrics.go Add Prometheus counter for bulk UTXO requests.
services/asset/httpimpl/http.go Register POST /api/v1/utxos route with optional per-route BodyLimit middleware.
services/asset/httpimpl/GetUTXOs.go Implement bulk binary request/response handler with concurrency cap and per-slot writes.
services/asset/httpimpl/GetUTXOs_test.go Add handler tests for ordering, error mapping, and fast-path behavior.
services/asset/httpimpl/GetUTXO.go Remove transaction fetch/parse; call GetUtxo with UTXOHash: nil.
services/asset/httpimpl/GetUTXO_test.go Update tests for unified NOT_FOUND message and ensure GetTransaction isn’t called.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread services/asset/httpimpl/GetUTXO.go
Comment thread services/asset/httpimpl/GetUTXOs.go Outdated
@icellan icellan self-assigned this May 26, 2026
@github-actions

github-actions Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-950 (192db50)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 144
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.754µ 1.779µ ~ 0.500
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 61.68n 61.48n ~ 0.400
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 61.63n 61.46n ~ 0.100
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 61.62n 61.51n ~ 0.200
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 29.88n 28.91n ~ 0.700
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 51.41n 49.38n ~ 0.100
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 104.9n 105.3n ~ 0.300
MiningCandidate_Stringify_Short-4 258.4n 258.3n ~ 1.000
MiningCandidate_Stringify_Long-4 1.890µ 1.894µ ~ 0.600
MiningSolution_Stringify-4 963.6n 967.4n ~ 0.200
BlockInfo_MarshalJSON-4 1.788µ 1.782µ ~ 0.700
NewFromBytes-4 122.8n 125.3n ~ 0.200
AddTxBatchColumnar_Validation-4 2.512µ 2.369µ ~ 0.200
OffsetValidationLoop-4 722.4n 720.3n ~ 0.400
Mine_EasyDifficulty-4 67.15µ 66.91µ ~ 0.100
Mine_WithAddress-4 7.000µ 6.977µ ~ 0.100
BlockAssembler_AddTx-4 0.02670n 0.02647n ~ 1.000
AddNode-4 11.20 11.19 ~ 1.000
AddNodeWithMap-4 11.99 11.43 ~ 0.400
DiskTxMap_SetIfNotExists-4 3.429µ 3.478µ ~ 0.700
DiskTxMap_SetIfNotExists_Parallel-4 3.394µ 3.313µ ~ 1.000
DiskTxMap_ExistenceOnly-4 302.8n 304.5n ~ 0.700
Queue-4 187.3n 187.8n ~ 0.100
AtomicPointer-4 4.783n 4.575n ~ 0.400
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 870.7µ 865.4µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/New/10K-4 793.3µ 787.5µ ~ 0.200
ReorgOptimizations/AllMarkFalse/Old/10K-4 104.9µ 103.4µ ~ 0.100
ReorgOptimizations/AllMarkFalse/New/10K-4 62.69µ 62.57µ ~ 0.700
ReorgOptimizations/HashSlicePool/Old/10K-4 54.86µ 54.24µ ~ 0.400
ReorgOptimizations/HashSlicePool/New/10K-4 11.60µ 11.85µ ~ 0.700
ReorgOptimizations/NodeFlags/Old/10K-4 4.664µ 4.637µ ~ 0.200
ReorgOptimizations/NodeFlags/New/10K-4 1.651µ 1.578µ ~ 0.700
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 9.425m 9.504m ~ 0.100
ReorgOptimizations/DedupFilterPipeline/New/100K-4 10.161m 9.857m ~ 0.200
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.078m 1.087m ~ 0.200
ReorgOptimizations/AllMarkFalse/New/100K-4 690.1µ 686.3µ ~ 0.100
ReorgOptimizations/HashSlicePool/Old/100K-4 657.6µ 628.5µ ~ 0.400
ReorgOptimizations/HashSlicePool/New/100K-4 293.2µ 294.7µ ~ 1.000
ReorgOptimizations/NodeFlags/Old/100K-4 50.19µ 50.23µ ~ 1.000
ReorgOptimizations/NodeFlags/New/100K-4 17.55µ 17.92µ ~ 0.400
TxMapSetIfNotExists-4 53.90n 52.62n ~ 0.700
TxMapSetIfNotExistsDuplicate-4 40.59n 39.94n ~ 0.100
ChannelSendReceive-4 619.3n 634.5n ~ 0.100
DirectSubtreeAdd/4_per_subtree-4 56.73n 57.38n ~ 0.700
DirectSubtreeAdd/64_per_subtree-4 29.82n 28.98n ~ 0.700
DirectSubtreeAdd/256_per_subtree-4 27.83n 27.90n ~ 0.700
DirectSubtreeAdd/1024_per_subtree-4 26.51n 26.45n ~ 0.400
DirectSubtreeAdd/2048_per_subtree-4 26.05n 26.10n ~ 0.400
SubtreeProcessorAdd/4_per_subtree-4 294.1n 299.5n ~ 0.100
SubtreeProcessorAdd/64_per_subtree-4 285.7n 292.3n ~ 0.400
SubtreeProcessorAdd/256_per_subtree-4 285.5n 286.9n ~ 0.400
SubtreeProcessorAdd/1024_per_subtree-4 276.7n 279.0n ~ 0.200
SubtreeProcessorAdd/2048_per_subtree-4 279.0n 284.7n ~ 0.100
SubtreeProcessorRotate/4_per_subtree-4 285.0n 290.0n ~ 0.400
SubtreeProcessorRotate/64_per_subtree-4 295.1n 280.6n ~ 0.100
SubtreeProcessorRotate/256_per_subtree-4 296.5n 276.9n ~ 0.100
SubtreeProcessorRotate/1024_per_subtree-4 291.8n 279.9n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 55.29n 55.14n ~ 0.400
SubtreeNodeAddOnly/64_per_subtree-4 36.27n 36.17n ~ 1.000
SubtreeNodeAddOnly/256_per_subtree-4 35.13n 35.21n ~ 0.700
SubtreeNodeAddOnly/1024_per_subtree-4 34.52n 34.69n ~ 0.600
SubtreeCreationOnly/4_per_subtree-4 110.5n 111.0n ~ 1.000
SubtreeCreationOnly/64_per_subtree-4 353.9n 349.6n ~ 0.100
SubtreeCreationOnly/256_per_subtree-4 1.232µ 1.224µ ~ 0.400
SubtreeCreationOnly/1024_per_subtree-4 3.796µ 3.793µ ~ 0.900
SubtreeCreationOnly/2048_per_subtree-4 6.783µ 6.760µ ~ 1.000
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 280.4n 283.4n ~ 0.400
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 276.9n 283.5n ~ 0.100
ParallelGetAndSetIfNotExists/1k_nodes-4 2.123m 2.125m ~ 1.000
ParallelGetAndSetIfNotExists/10k_nodes-4 5.385m 5.463m ~ 0.700
ParallelGetAndSetIfNotExists/50k_nodes-4 7.523m 8.114m ~ 0.100
ParallelGetAndSetIfNotExists/100k_nodes-4 10.32m 10.86m ~ 0.100
SequentialGetAndSetIfNotExists/1k_nodes-4 1.814m 1.816m ~ 1.000
SequentialGetAndSetIfNotExists/10k_nodes-4 4.456m 5.170m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 15.04m 14.19m ~ 0.700
SequentialGetAndSetIfNotExists/100k_nodes-4 26.78m 27.63m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 2.161m 2.235m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 8.576m 8.762m ~ 0.400
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 13.95m 14.19m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 1.813m 1.889m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 8.263m 9.115m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 45.16m 49.87m ~ 0.100
CalcBlockWork-4 503.0n 501.8n ~ 0.700
CalculateWork-4 736.3n 682.1n ~ 0.700
BuildBlockLocatorString_Helpers/Size_10-4 1.343µ 1.344µ ~ 0.500
BuildBlockLocatorString_Helpers/Size_100-4 15.62µ 15.12µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_1000-4 127.0µ 129.6µ ~ 0.100
CatchupWithHeaderCache-4 104.5m 104.5m ~ 0.700
_BufferPoolAllocation/16KB-4 4.401µ 4.098µ ~ 0.700
_BufferPoolAllocation/32KB-4 10.04µ 11.75µ ~ 0.700
_BufferPoolAllocation/64KB-4 20.24µ 19.37µ ~ 0.400
_BufferPoolAllocation/128KB-4 33.30µ 37.60µ ~ 0.700
_BufferPoolAllocation/512KB-4 129.1µ 157.0µ ~ 0.100
_BufferPoolConcurrent/32KB-4 19.63µ 20.70µ ~ 0.700
_BufferPoolConcurrent/64KB-4 31.76µ 29.65µ ~ 0.100
_BufferPoolConcurrent/512KB-4 149.1µ 151.4µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 673.0µ 681.5µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/32KB-4 682.4µ 679.7µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/64KB-4 682.5µ 685.1µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/128KB-4 678.8µ 677.6µ ~ 1.000
_SubtreeDeserializationWithBufferSizes/512KB-4 641.7µ 681.0µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 36.34m 37.16m ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/32KB-4 36.47m 36.67m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/64KB-4 36.48m 36.86m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/128KB-4 36.40m 36.72m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/512KB-4 36.28m 36.14m ~ 0.100
_PooledVsNonPooled/Pooled-4 740.2n 743.1n ~ 0.300
_PooledVsNonPooled/NonPooled-4 8.537µ 9.077µ ~ 0.700
_MemoryFootprint/Current_512KB_32concurrent-4 6.485µ 6.476µ ~ 1.000
_MemoryFootprint/Proposed_32KB_32concurrent-4 10.161µ 9.473µ ~ 0.100
_MemoryFootprint/Alternative_64KB_32concurrent-4 9.575µ 9.361µ ~ 0.100
_prepareTxsPerLevel-4 427.7m 416.6m ~ 1.000
_prepareTxsPerLevelOrdered-4 3.824m 3.670m ~ 0.400
_prepareTxsPerLevel_Comparison/Original-4 419.3m 414.7m ~ 0.700
_prepareTxsPerLevel_Comparison/Optimized-4 3.882m 3.683m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 1.304m 1.283m ~ 0.700
SubtreeSizes/10k_tx_16_per_subtree-4 307.1µ 298.0µ ~ 0.700
SubtreeSizes/10k_tx_64_per_subtree-4 72.21µ 72.29µ ~ 1.000
SubtreeSizes/10k_tx_256_per_subtree-4 18.00µ 17.90µ ~ 0.700
SubtreeSizes/10k_tx_512_per_subtree-4 8.803µ 8.834µ ~ 0.100
SubtreeSizes/10k_tx_1024_per_subtree-4 4.381µ 4.383µ ~ 0.700
SubtreeSizes/10k_tx_2k_per_subtree-4 2.169µ 2.176µ ~ 0.400
BlockSizeScaling/10k_tx_64_per_subtree-4 69.53µ 69.77µ ~ 1.000
BlockSizeScaling/10k_tx_256_per_subtree-4 17.39µ 17.38µ ~ 1.000
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.258µ 4.525µ ~ 0.100
BlockSizeScaling/50k_tx_64_per_subtree-4 371.1µ 389.9µ ~ 0.100
BlockSizeScaling/50k_tx_256_per_subtree-4 86.72µ 94.00µ ~ 0.100
BlockSizeScaling/50k_tx_1024_per_subtree-4 21.67µ 21.83µ ~ 0.400
SubtreeAllocations/small_subtrees_exists_check-4 149.7µ 152.1µ ~ 0.400
SubtreeAllocations/small_subtrees_data_fetch-4 157.3µ 158.7µ ~ 0.700
SubtreeAllocations/small_subtrees_full_validation-4 301.9µ 307.2µ ~ 0.400
SubtreeAllocations/medium_subtrees_exists_check-4 8.819µ 8.748µ ~ 0.100
SubtreeAllocations/medium_subtrees_data_fetch-4 9.231µ 9.304µ ~ 0.700
SubtreeAllocations/medium_subtrees_full_validation-4 17.35µ 17.33µ ~ 0.700
SubtreeAllocations/large_subtrees_exists_check-4 2.103µ 2.081µ ~ 0.400
SubtreeAllocations/large_subtrees_data_fetch-4 2.238µ 2.218µ ~ 0.100
SubtreeAllocations/large_subtrees_full_validation-4 4.351µ 4.311µ ~ 0.100
StoreBlock_Sequential/BelowCSVHeight-4 256.9µ 252.9µ ~ 0.700
StoreBlock_Sequential/AboveCSVHeight-4 253.5µ 252.4µ ~ 0.400
GetUtxoHashes-4 259.3n 255.3n ~ 0.700
GetUtxoHashes_ManyOutputs-4 47.34µ 42.45µ ~ 0.100
_NewMetaDataFromBytes-4 227.8n 227.4n ~ 0.100
_Bytes-4 398.4n 397.4n ~ 0.700
_MetaBytes-4 136.3n 136.3n ~ 0.800

Threshold: >10% with p < 0.05 | Generated: 2026-05-27 16:55 UTC

icellan added 2 commits May 26, 2026 18:50
…1/utxos

Address review feedback on PR bsv-blockchain#950.

Critical fix:
- services/asset/repository/repository.go:917 dereferenced spend.UTXOHash for
  a debug log line. The new POST /api/v1/utxos handler (and the simplified
  GET /api/v1/utxo path) pass UTXOHash: nil, which would have panicked here
  at runtime. Log TxID:Vout instead — both fields are always populated.
- New TestGetUtxoNilUTXOHash exercises the real Repository (not the mock)
  against a sqlitememory store so the panic would surface in CI. The
  handler-layer tests used asset/repository.Mock which doesn't traverse
  this code path, which is why the bug slipped through the first review.

Documentation:
- swagger_routes.go: add the missing POST /api/v1/utxos entry.
- docs/references/services/asset_reference.md: document the new endpoint
  (method, request/response layout, body cap, error responses).
- docs/topics/services/img/plantuml/assetserver/asset_server_http_get_utxo.{puml,svg}:
  extend the sequence diagram with the bulk lookup flow.
- settings/asset_settings.go: reword asset_httpBodyLimit desc/longdesc to
  make clear it scopes only to /utxos, not the whole Asset HTTP surface.
- services/asset/httpimpl/http.go: matching comment tweak.

Cleanups:
- services/asset/httpimpl/GetUTXO_test.go: strip seven dead "tx := bt.NewTx() +
  GetTransaction mock" setups left over from the removed code path; drop the
  now-unused go-bt import; switch back to "err :=" where the declaration was
  removed.
- services/asset/httpimpl/GetUTXOs.go: drop the strings.Contains("not found")
  fallback (errors.Is(err, errors.ErrNotFound) suffices and is consistent with
  the always-use-teranode/errors convention); tighten the response slot slice
  to its exact 48-byte bounds.
… modes

Mirror the GET /api/v1/utxo {BINARY_STREAM, HEX, JSON} pattern on the bulk
endpoint. All three routes accept the same 36-byte binary request body; only
the response format differs.

- POST /api/v1/utxos      — binary (fixed 48-byte records, indexable by i*48)
- POST /api/v1/utxos/hex  — same binary blob, lowercase-hex encoded
- POST /api/v1/utxos/json — JSON array of utxo.SpendResponse in input order

GetUTXOs now takes a ReadMode parameter, the goroutines fill a
[]*utxo.SpendResponse slice (preserving input order via slot writes), and a
final writeUTXOsResponse helper serializes per mode. The binary record layout
is unchanged.

- swagger_routes.go: annotations for the two new routes.
- docs/references/services/asset_reference.md: documents all three modes.
- docs/topics/.../asset_server_http_get_utxo.{puml,svg}: diagram updated.
- New tests: HEX mode (hex-decode + record-layout assertions), JSON mode
  (array shape + per-record fields + order), JSON mode on empty body (empty array).

@oskarszoon oskarszoon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve with discussion items. 573/573 -race clean. Settings loader correctly wired this time (settings.go:215 reads asset_httpBodyLimit#933 / #643 P1.1 pattern doesn't repeat).

Worth confirming before merge

  1. Aerospike GetSpend never populates LockTime (stores/utxo/aerospike/get.go:272-275). SQL path does via coinbaseSpendingHeight. Binary/HEX response's lockTime is always 0 on Aerospike backend. Pre-existing, but bulk endpoint makes it machine-visible. Intentional → document in swagger; oversight → one-line fix.

  2. Endpoint anonymous + unrated. apiGroup only wires Recover + BanList + CORS + Gzip + securityHeaders. PR #643 (auth + rate limit) still CHANGES_REQUESTED. Single 4MB POST = ~116k Aerospike Gets at 1024 concurrency through the shared uaerospike.Client semaphore (~256) that validator + block validation + propagation also use. Per-request cost is ~116k× the old single-UTXO endpoint. Worth a tracking issue for gating under asset_httpHeavyRateLimit once #643 lands.

  3. Dropped UTXOHash check was the only run-time detector for writer-side vout % batchSize slot-indexing regressions in Aerospike. Future writer bug would silently return wrong data. Add writer-side regression test (GetSpend(vout=X, UTXOHash=nil) after Spend(vout=Y) with batchSize > 1) or hash-mismatch metric.

  4. Doc gaps:

    • asset_httpBodyLimit missing from docs/references/settings/services/asset_settings.md — same shape as #643 v3.
    • asset_reference.md:771-774 doesn't call out the message-collapse behaviour change ("tx not found" + "vout out of range" → "NOT_FOUND (3): UTXO not found"). Scripted clients break silently.

Nits

  • GetUTXO.go:132 loose strings.Contains(err.Error(), "not found") while GetUTXOs.go:149 uses errors.Is(err, errors.ErrNotFound) — tighten singular.
  • GetUTXOs_test.go mixes assert with project require-only convention.
  • GetUTXO.go:37 docstring calls :hash "UTXO hash"; it's the txid.
  • No 413 body-cap test — GetMockHTTP bypasses BodyLimit middleware.
  • c.Request().RemoteAddr in tracing log at GetUTXOs.go:92 — wrong behind a reverse proxy, consistent with pre-existing GetUTXO.go:91.

errgroup fan-out at 1024 safe (per-slot writes, i := i capture, client disconnect cancels gCtx). Binary layout 8+4+4+32=48 verified. UTXOHash == nil correct in both stores; SQL latent nil-deref fixed at sql.go:3320. a3e578afb nil-safe logging verified end-to-end. Mode dispatch closure-bound at route registration — no header-tricking surface.

@ordishs

ordishs commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Review

Clean design overall — the binary layout is well-documented, the store nil-UTXOHash tolerance and the repo logging fix are correct and necessary, and test coverage is broad (order preservation, mixed statuses, nil-hash assertions, HEX/JSON modes, the real-repo nil-log test). One critical, remotely-triggerable crash blocks merge.


🔴 CRITICAL — Unauthenticated DoS: out-of-range vout panics the aerospike store and crashes the asset server

Both new paths now pass a client-controlled vout straight to GetSpend with no bounds check. The aerospike store indexes the per-batch UTXO list without validating the offset:

stores/utxo/aerospike/get.go:186

b, ok := utxos[spend.Vout%uint32(s.utxoBatchSize)].([]byte)
  • CalculateKeySource(hash, vout, 128) returns num = vout/128, so every vout in 0..127 maps to the same batch-0 record, which exists for every tx (util/uaerospike/client.go:505).
  • The Utxos bin is utxos[start:end], sized to the actual output count, not padded (stores/utxo/aerospike/create.go:574-577). A 2-output tx stores a 2-element list.
  • So utxos[vout%128] with e.g. vout=127 on a 2-element list → index out of range panic.

This indexing is pre-existing but was previously unreachable with bad vouts: the only HTTP caller (GetUTXO) validated voutUint32 >= uint32(len(tx.Outputs)) → 404 before hitting the store. This PR removes that guard and adds a second unvalidated path.

Impact differs by endpoint — and the bulk one is fatal:

Path Panic goroutine middleware.Recover() covers it? Result
GET /api/v1/utxo/:hash?vout=N echo request goroutine ✅ yes (http.go:129) recovered → 500 (regression from the old clean 404)
POST /api/v1/utxos errgroup child goroutine (GetUTXOs.go:524) norecover() is goroutine-local; errgroup does not recover unrecovered panic → asset process crashes

Full chain for the bulk path has no recover anywhere: GetUTXOs goroutine → repository.GetUtxo (repository.go:911) → aerospike GetSpend (panics). Echo's recover middleware only protects the request goroutine, not the ones errgroup spawns.

Reproduction (unauthenticated, one request):

POST /api/v1/utxos
body = <32-byte txid of ANY tx with <128 outputs> || <uint32 LE vout in (numOutputs, 127]>

The coinbase of any block (1 output, well-known txid) with vout=1 suffices.

Required fix (root cause, protects all callers, minimal): bounds-check the offset in aerospike/get.go and treat overflow as not-found, mirroring the existing ErrKeyNotFound branch:

idx := spend.Vout % uint32(s.utxoBatchSize)
if int(idx) >= len(utxos) {
    return &utxo.SpendResponse{Status: int(utxo.Status_NOT_FOUND)}, nil
}
b, ok := utxos[idx].([]byte)

SQL is already safe (WHERE ... AND o.idx = $2ErrNoRowsStatus_NOT_FOUND, sql.go:3303-3310), so the gap is aerospike-only (production).

Strongly recommend additionally isolating panics in the errgroup body so any future store-level panic degrades to 500 instead of taking down the process — a public fan-out endpoint should never let a per-record panic escape the goroutine.

Test gap that hid this: every GetUTXOs/GetUTXO test mocks the repository, so "Vout out of range" returns a mocked Status_NOT_FOUND and never exercises the real store indexing. Per the repo's testing rule (use sqlitememory, don't mock the store), an out-of-range-vout test against a real store would have caught the aerospike-vs-SQL divergence. Please add one.


🟡 Minor

  • 413 can be masked as 500. GetUTXOs reads the body via io.ReadAll (GetUTXOs.go:493). Echo's BodyLimit returns 413 up-front only via the Content-Length check; a client streaming without an accurate Content-Length trips the limit inside Read, so io.ReadAll returns ErrStatusRequestEntityTooLarge and the handler wraps it as 500. Common clients send Content-Length so the documented 413 holds for them — consider detecting echo.ErrStatusRequestEntityTooLarge and surfacing 413.
  • Behavior change (already flagged by author): "tx not found" vs "output index out of range" now collapse to NOT_FOUND (3): UTXO not found. Documented, status code unchanged — just confirm no internal client greps the old message strings.
  • Error counter only on success. prometheusAssetHTTPGetUTXOs is incremented only on the 200 path; 400/413/500 record nothing. Mirrors existing GetUTXO, so the bulk endpoint's failure rate will be invisible.

🟢 Nits

  • i := i (GetUTXOs.go:516) is dead since Go 1.22; go.mod is go 1.26.0.
  • writeUTXOsRecord doc says [...4 vin][32 txid]; PR body / asset_reference.md say "spendingVin"/"spendingTxID" — same thing, align wording.

Verdict

Request changes. Endpoint design, docs, and test breadth are strong. But removing the vout < len(outputs) guard exposes a pre-existing unchecked slice index in the aerospike store, and routing it through errgroup goroutines turns it into a one-request remote crash of the asset server.

Before merge:

  1. Bounds-check the offset in aerospike/get.go (return Status_NOT_FOUND).
  2. Add panic isolation around the errgroup per-record body.
  3. Add a real-store (sqlitememory + aerospike) out-of-range-vout test.
  4. (Optional) map ErrStatusRequestEntityTooLarge from io.ReadAll to 413.

Address ordishs' critical finding on PR bsv-blockchain#950.

Both new code paths (POST /api/v1/utxos, simplified GET /api/v1/utxo)
forwarded a client-controlled vout straight to the store with no bounds
check. The aerospike store indexed the per-batch utxo list without
validating the offset — a vout greater than the actual output count
panicked with index-out-of-range. In the bulk endpoint that panic
escaped to an errgroup goroutine that echo.middleware.Recover does not
cover, crashing the asset process from a single unauthenticated request.

The bug was pre-existing in the store but previously unreachable: the old
GetUTXO handler validated `vout < len(tx.Outputs)` against the parsed tx
before calling GetSpend. The single-endpoint optimisation removed that
guard; the bulk endpoint never had one.

Fixes:
- stores/utxo/aerospike/get.go: bounds-check `vout % batchSize` against
  the actual length of the Utxos bin; return Status_NOT_FOUND on overflow,
  mirroring the existing ErrKeyNotFound branch. SQL store already safe
  (WHERE ... AND o.idx = $2 -> ErrNoRows -> Status_NOT_FOUND).
- services/asset/httpimpl/GetUTXOs.go: wrap each errgroup goroutine body
  in a deferred recover that converts any future store-level panic into
  a 500 errgroup error with a logged stack trace, so a public fan-out
  endpoint can never let a per-record panic escape the process.

Regression guards:
- stores/utxo/sql/sql_test.go: assert out-of-range vout returns
  Status_NOT_FOUND, not error/panic (locks the cross-store contract).
- stores/utxo/aerospike/aerospike_server_test.go: same contract on the
  real aerospike store, in the existing TestAerospike subtree.
- services/asset/httpimpl/GetUTXOs_test.go: panic-in-store test asserts
  the bulk handler returns 500 instead of crashing.

Test-gap fix: every previous GetUTXOs/GetUTXO test mocked the repository,
so out-of-range vout returned a mocked Status_NOT_FOUND and never
exercised the real store indexing. The new SQL+aerospike tests close
that gap.

Other review items from this round:
- services/asset/httpimpl/GetUTXOs.go: map echo.ErrStatusRequestEntityTooLarge
  from io.ReadAll to 413 so streaming clients without Content-Length get
  the documented status instead of 500.
- Drop the now-redundant `i := i` (go.mod is go 1.26, loop-var fix landed in 1.22).
- writeUTXOsRecord doc comment now uses spendingVin/spendingTxID to match
  public docs.
- docs/references/services/asset_reference.md: document the pre-existing
  Aerospike/SQL LockTime divergence (Aerospike returns tx.LockTime; SQL
  returns coinbaseSpendingHeight). Bulk endpoint surfaces it more visibly
  but the gap pre-dates this PR.
@icellan

icellan commented May 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review @ordishs — the panic chain is exactly as you described and the test gap is a fair critique. Pushed 287eb7085 addressing every item.

Critical fixes

  1. Aerospike bounds-check. stores/utxo/aerospike/get.go:186 now guards idx >= len(utxos) and returns Status_NOT_FOUND, mirroring the existing ErrKeyNotFound branch. SQL was already safe.
  2. Errgroup panic isolation. Each GetUTXOs goroutine body now runs under a deferred recover() that converts any panic into a 500 errgroup error and logs the stack trace. A per-record panic can no longer escape the request goroutine, regardless of which store backend is in use.
  3. Real-store regression tests. Added:
    • stores/utxo/sql/sql_test.go::TestGetSpendNilUTXOHash/out-of-range_vout_returns_NOT_FOUND (sqlitememory, runs locally).
    • stores/utxo/aerospike/aerospike_server_test.go::TestAerospike/aerospike_get_spend_out_of_range_vout (testcontainers, runs in CI).
    • services/asset/httpimpl/GetUTXOs_test.go::TestGetUTXOs/panic_in_goroutine_becomes_500_... — mocks the repo to panic and asserts 500, locking the panic-isolation contract.

Minor

  • 413 mapping. io.ReadAll now detects echo.ErrStatusRequestEntityTooLarge and surfaces 413 explicitly, so streaming clients without Content-Length get the documented status instead of a generic 500.
  • Counter only on success. Acknowledged — mirrors GetUTXO and other handlers. Happy to add failure label-vector counters as a follow-up if you'd like; left out of this PR to keep parity with the rest of the asset surface.
  • Message-string behaviour change. Confirmed no internal client greps the old strings.

Nits

  • i := i dropped.
  • writeUTXOsRecord doc comment now uses spendingVin/spendingTxID, matching the PR description and asset_reference.md.

Re @oskarszoon's items

Re the github-actions bot's "error format mismatch"

The format string "error getting utxo %s:%d" with three args (txHash, vout, err) is the project's standard pattern, not a bug: errors.New strips a trailing error argument and attaches it as the wrapped error (errors/errors.go:328-337), then formats the remaining params. The wrapped error shows in the chain via %w-style unwrap, not via %v interpolation. Same pattern in stores/utxo/aerospike/spend.go etc.

@ordishs

ordishs commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Re-review — all blocking feedback addressed ✅

Re-reviewed at 830258f26 (fix commit 287eb7085, "prevent remote panic via out-of-range vout"). Every point from the previous review is resolved, and the fix matches the recommended approach.

🔴 Critical DoS — fixed at the root

stores/utxo/aerospike/get.go now bounds-checks the batch offset before indexing and returns NOT_FOUND on overflow, mirroring the ErrKeyNotFound branch:

idx := spend.Vout % uint32(s.utxoBatchSize)
if int(idx) >= len(utxos) {
    return &utxo.SpendResponse{Status: int(utxo.Status_NOT_FOUND)}, nil
}
b, ok := utxos[idx].([]byte)

Verified correct across single-batch, cross-batch, and exact-boundary vouts (e.g. 200-output tx: vout=199→valid, vout=200→NOT_FOUND via offset guard, vout=300→NOT_FOUND via missing record). SQL was already safe; both stores now have explicit coverage.

🟢 Defense-in-depth — added

GetUTXOs.go wraps each errgroup goroutine body in a recover() that logs debug.Stack() and converts any panic into a 500, so a future store-level panic degrades the request instead of taking down the process — exactly the isolation a public fan-out endpoint needs.

🟢 413 mapping — added and confirmed live

io.ReadAll now maps echo.ErrStatusRequestEntityTooLarge → 413. I checked that teranode/errors.Is delegates to stdlib errors.Is (errors/errors.go:903), so the foreign echo sentinel actually matches — not dead code. The documented 413 contract now holds for both Content-Length and chunked clients.

🟢 Test gap — closed at all three levels

  • stores/utxo/aerospike/aerospike_server_test.goaerospike_get_spend_out_of_range_vout (the real production store; would have panicked pre-fix).
  • stores/utxo/sql/sql_test.goout-of-range vout returns NOT_FOUND, not error or panic.
  • GetUTXOs_test.gopanic in goroutine becomes 500 instead of crashing the process (forces a mock panic, asserts 500).

The i := i nit was also dropped (Go 1.26 loop semantics).

Verification I ran

  • go test ./stores/utxo/sql/PASS, including all three TestGetSpendNilUTXOHash subcases (nil-skips-check, wrong-hash-fails, out-of-range→NOT_FOUND).
  • The httpimpl and repository/aerospike tests I could not run in my environment — they transitively import the gobdk cgo module and my local Xcode license isn't accepted (cgo-on blocked, cgo-off excludes gobdk). Reviewed those by inspection; logic is sound. Worth a glance at CI to confirm the -race run is green on services/asset/httpimpl/... and the aerospike integration suite.

Non-blocking

  • The 413 read-path branch has no test (hard to exercise without a reader that lies about Content-Length) — optional.
  • prometheusAssetHTTPGetUTXOs is still only incremented on success, so the bulk endpoint's failure rate is invisible — pre-existing pattern, fine to defer.

LGTM once CI is green. Nice, thorough turnaround.

@oskarszoon oskarszoon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve at 830258f260. DoS chain closed at both layers (defense-in-depth verified).

The critical

aerospike/get.go:192-197 bounds-check returns Status_NOT_FOUND before slice index — matches existing ErrKeyNotFound shape. GetUTXOs.go:141-156 errgroup per-goroutine defer recover() is the first statement, named return propagates panic→500 through g.Wait(). Surgical fix + general belt.

Tests cover all three layers:

  • sql_test.go:396-413 — sqlitememory cross-store contract.
  • aerospike_server_test.go:226-251 — testcontainers, vout=99 on 2-output tx (panics pre-fix).
  • GetUTXOs_test.go:255-281 — handler-level panic→500.

All 673 tests -race clean.

Prior items addressed

  • LockTime divergence (SQL coinbase maturity height vs Aerospike nLockTime) documented in asset_reference.md:790.
  • asset_httpBodyLimit cap + 413 mapping documented in asset_reference.md:788-789.
  • UTXOHash nil-skip design noted in both store code comments.
  • 413 mapping for echo.ErrStatusRequestEntityTooLarge cleanly separates 4xx from 5xx — better ops alerting.

Follow-ups (not blocking)

  • Operator-settings doc (docs/references/settings/services/asset_settings.md) doesn't list asset_httpBodyLimit yet — one-line add.
  • Recover handler emits a full stack trace per panicking record. Closed paths are safe; if a future new panic source ever slips through, 116k records × ~5KB ≈ ~580MB log output per malicious request. Sample first stack only.
  • Anonymous + unrated endpoint stays on the follow-up list pending #643.

The panic-recovery defer in the bulk /api/v1/utxos goroutine logged the
recovered value with `\n%s` + `debug.Stack()`, which embeds raw newlines
and breaks log parsers. The project convention is single-line log
messages. Match the existing pattern in services/propagation/Server.go:
log just the panic value, txid, and vout on one line.

Also document the panic-recovery 500 path in the handler doc comment so
operators know that some 500s carry a logged-but-redacted panic value.

@ordishs ordishs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — CI is green, including the services/asset/httpimpl race run and the aerospike out-of-range-vout regression test.

The critical remote-panic DoS (out-of-range vout → index-out-of-range in the aerospike store, surfacing in an errgroup goroutine outside echo's recover) is fixed at the root via the batch-offset bounds check in aerospike/get.go, with defense-in-depth recover in the fan-out goroutines, the 413 mapping, and regression tests at the aerospike, SQL, and handler layers. All earlier feedback addressed. Nice turnaround.

Adopt PR bsv-blockchain#643's asset HTTP design:
- Drop my per-route BodyLimit on POST /utxos in favour of the new top-level
  global middleware (asset_httpBodyLimit, default 100MB).
- Add heavyMW()... to the three POST /utxos* routes so they are gated by the
  same tiered rate limiter as POST /subtree/:hash/txs. Closes the
  anonymous/unrated gap flagged in PR bsv-blockchain#950 review.
- Drop the duplicate HTTPBodyLimit definition (settings/asset_settings.go) and
  the duplicate loader (settings/settings.go) — main's wins.
- Update docs (asset_reference.md, GetUTXOs.go doc comment) for the new
  global cap, the rate limiter, and the 429 response code.

Conflicts resolved: settings/asset_settings.go (kept main's HTTPBodyLimit
definition wholesale).
Comment thread services/asset/httpimpl/swagger_routes.go Outdated
… fix swagger doc

Two PR bsv-blockchain#950 follow-ups from the latest bot runs:

1. swagger_routes.go:384 still said "default 4MB" but the post-merge cap is
   100MB (the global asset_httpBodyLimit from PR bsv-blockchain#643). Aligned with
   asset_reference.md.

2. SonarCloud flagged the GetUTXOs handler with cognitive complexity 31
   (limit 15). Extract two helpers — no behaviour change:
   - readUTXOsBody(c): read + 413 mapping + length-multiple validation.
   - fetchOneUTXO(ctx, txHash, vout): per-record store lookup with
     ErrNotFound / nil-resp / wrapped-error semantics. The panic-recover
     defer stays inline in the goroutine wrapper because it needs the
     closure variables.

   Handler now reads top-to-bottom: tracing → read body → fast-path empty →
   fan-out → wait → metrics → serialize.

(The two MAJOR "filename does not follow snake_case" findings from Sonar are
false positives — every Get*.go file under services/asset/httpimpl is
CamelCase. Will flag in the PR reply.)
@icellan

icellan commented May 27, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the latest bot/Sonar findings in 344d159.

Fixed

  • github-actions[bot] inline (swagger_routes.go:384) — `default 4MB` → `default 100MB`. The merge from main brought in PR feat: add asset service rate limiting, peer authentication and request visibility #643's global `asset_httpBodyLimit` (100MB); I'd missed updating the swagger annotation to match `asset_reference.md`.
  • SonarCloud CRITICAL — `GetUTXOs` cognitive complexity 31 (limit 15). Extracted two helpers, no behaviour change:
    • `readUTXOsBody(c)` — body read + 413 mapping + length-multiple validation.
    • `fetchOneUTXO(ctx, txHash, vout)` — per-record store lookup with the ErrNotFound / nil-resp / wrapped-error semantics.
      The panic-recover defer stays inline in the goroutine wrapper because it needs the closure variables (`txHash`, `vout`, `retErr`).

Flagging as false positive (please confirm)

  • SonarCloud 2× MAJOR — "Filename does not follow snake_case.go convention" on `GetUTXOs.go` and `GetUTXOs_test.go`. Every `Get*.go` file under `services/asset/httpimpl/` (`GetUTXO.go`, `GetTransaction.go`, `GetBlock.go`, `GetBlockHeader.go`, ...) is CamelCase; my files match the established directory convention. Happy to rename if you'd prefer to align with Sonar (would need to rename the existing dozen+ files too for consistency).

All 10 `TestGetUTXOs` subtests pass under `-race`; `make lint-new` clean.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@icellan icellan merged commit 1f7a475 into bsv-blockchain:main May 27, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants