Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions internal/api/localstatequery.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

"github.com/blinklabs-io/cardano-node-api/internal/node"
"github.com/blinklabs-io/gouroboros/ledger"
"github.com/blinklabs-io/gouroboros/protocol/localstatequery"
"github.com/gin-gonic/gin"
)

Expand All @@ -30,6 +31,7 @@
group.GET("/tip", handleLocalStateQueryTip)
group.GET("/era-history", handleLocalStateQueryEraHistory)
group.GET("/protocol-params", handleLocalStateQueryProtocolParams)
group.GET("/utxos/search-by-asset", handleLocalStateQuerySearchUTxOsByAsset)
// TODO: uncomment after this is fixed:
// - https://github.com/blinklabs-io/gouroboros/issues/584
// group.GET("/genesis-config", handleLocalStateQueryGenesisConfig)
Expand Down Expand Up @@ -374,3 +376,139 @@
//}
c.JSON(200, genesisConfig)
}

type responseLocalStateQuerySearchUTxOsByAsset struct {
UTxOs []utxoItem `json:"utxos"`
Count int `json:"count"`
}

type utxoItem struct {
TxHash string `json:"tx_hash"`
Index uint32 `json:"index"`
Address string `json:"address"`
Amount uint64 `json:"amount"`
Assets interface{} `json:"assets,omitempty"`
}

Comment on lines +380 to +392
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the file mentioned in the review
cat -n internal/api/localstatequery.go | head -100

Repository: blinklabs-io/cardano-node-api

Length of output: 3653


🏁 Script executed:

# Find the context around lines 380-392 to understand the structure better
sed -n '370,400p' internal/api/localstatequery.go

Repository: blinklabs-io/cardano-node-api

Length of output: 891


🏁 Script executed:

# Search for where utxoItem is created/populated to see what's assigned to Assets
rg "utxoItem\{|utxoItem(" internal/api/ -A 5 -B 2

Repository: blinklabs-io/cardano-node-api

Length of output: 163


🏁 Script executed:

# Search for json encoding/marshaling of utxoItem
rg "json\.Marshal|json\.Unmarshal" internal/api/localstatequery.go -B 3 -A 3

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Find the handler implementation
sed -n '392,500p' internal/api/localstatequery.go

Repository: blinklabs-io/cardano-node-api

Length of output: 2941


🏁 Script executed:

# Search for where utxoItem is created
rg "utxoItem" internal/api/ -A 3 -B 1

Repository: blinklabs-io/cardano-node-api

Length of output: 1270


🏁 Script executed:

# Get more of the handler to see how Assets is populated
sed -n '470,530p' internal/api/localstatequery.go

Repository: blinklabs-io/cardano-node-api

Length of output: 1137


🏁 Script executed:

# Search for test files that might show how this is used
find . -name "*localstatequery*test*" -o -name "*test*localstatequery*" | head -20

Repository: blinklabs-io/cardano-node-api

Length of output: 115


🏁 Script executed:

# Check the test file to see what the actual JSON looks like
cat -n ./openapi/test/api_localstatequery_test.go

Repository: blinklabs-io/cardano-node-api

Length of output: 3628


🏁 Script executed:

# Search for the Assets type definition - likely in ledger package
rg "type.*Assets" --type go -B 2 -A 5

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Check if there's a custom JSON marshaler for the assets type
rg "MarshalJSON|UnmarshalJSON" internal/api/localstatequery.go

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Check what types are imported and understand Assets better
head -30 internal/api/localstatequery.go

Repository: blinklabs-io/cardano-node-api

Length of output: 1145


🏁 Script executed:

# Search for how assets are used elsewhere in codebase
rg "Assets\(\)|output\.Assets" internal/ -B 2 -A 3

Repository: blinklabs-io/cardano-node-api

Length of output: 1028


🏁 Script executed:

# Look for any related issue discussions or comments about Assets
rg -i "asset.*interface|interface.*asset|unstable|json.*asset" internal/api/ -B 2 -A 2

Repository: blinklabs-io/cardano-node-api

Length of output: 926


🏁 Script executed:

# Check if there's any example or documentation about JSON format
rg -i "asset.*json|json.*format" . --type md --type txt

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Check go.mod to understand the gouroboros dependency
cat go.mod | grep -A 2 -B 2 "gouroboros"

Repository: blinklabs-io/cardano-node-api

Length of output: 273


🏁 Script executed:

# Search for any integration tests or examples that might show actual JSON output
find . -type f -name "*.go" -exec grep -l "search-by-asset\|SearchUTxOsByAsset" {} \;

Repository: blinklabs-io/cardano-node-api

Length of output: 106


🏁 Script executed:

# Look for any documentation about the Assets response format
find . -type f \( -name "*.md" -o -name "*.yaml" -o -name "*.yml" \) -exec grep -l "assets\|Asset" {} \;

Repository: blinklabs-io/cardano-node-api

Length of output: 104


🏁 Script executed:

# Check if there are any examples or docs in openapi spec
find . -name "*openapi*" -o -name "*swagger*" | head -10

Repository: blinklabs-io/cardano-node-api

Length of output: 248


🏁 Script executed:

# Check the OpenAPI spec for the Assets field definition
rg "responseLocalStateQuerySearchUTxOsByAsset|utxoItem|Assets" ./docs/swagger.yaml -B 3 -A 10

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Also check the JSON version
rg "responseLocalStateQuerySearchUTxOsByAsset|utxoItem|Assets" ./docs/swagger.json -B 3 -A 10

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Check the openapi-config to understand how the spec is generated
cat -n openapi-config.yml

Repository: blinklabs-io/cardano-node-api

Length of output: 186


🏁 Script executed:

# Read the swagger.yaml file - check size first
wc -l ./docs/swagger.yaml

Repository: blinklabs-io/cardano-node-api

Length of output: 96


🏁 Script executed:

# Search more specifically in swagger for utxo or asset
grep -i "utxo\|asset" ./docs/swagger.yaml | head -30

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Check if Assets field exists in swagger
cat ./docs/swagger.yaml | grep -A 10 -B 10 "assets"

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Read the full swagger file
cat ./docs/swagger.yaml

Repository: blinklabs-io/cardano-node-api

Length of output: 7742


🏁 Script executed:

# Let's also check what the gouroboros library provides - search for any local copies or vendored code
find . -path ./vendor -prune -o -type d -name "gouroboros*" -print

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Check if there are any other API response structures we can learn from
rg "type response.*struct" internal/api/ -A 5 | head -50

Repository: blinklabs-io/cardano-node-api

Length of output: 2643


🏁 Script executed:

# Let's check what type the Assets() method returns - search for ledger package
rg "func.*Assets\(\)" . --type go

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Check if there are integration tests or real examples showing the JSON output
find . -type f -name "*.go" -exec grep -l "Assets.*json\|json.*Assets" {} \;

Repository: blinklabs-io/cardano-node-api

Length of output: 106


🏁 Script executed:

# Let's check if there's documentation or examples of how the API is actually used
find . -type f \( -name "*.md" -o -name "*.txt" -o -name "README*" \) -exec grep -l "search-by-asset\|Assets" {} \;

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Check git history to see if this endpoint was recently added
git log --oneline internal/api/localstatequery.go | head -20

Repository: blinklabs-io/cardano-node-api

Length of output: 126


🏁 Script executed:

# Check the recent commit to understand context
git show f8069f1 --stat

Repository: blinklabs-io/cardano-node-api

Length of output: 5613


🏁 Script executed:

# Let's also look at the full diff of that commit for localstatequery.go
git show f8069f1 -- internal/api/localstatequery.go | head -200

Repository: blinklabs-io/cardano-node-api

Length of output: 5592


🏁 Script executed:

# Check if there are any TODOs or FIXMEs related to Assets
rg "TODO|FIXME|XXX" internal/api/localstatequery.go -B 2 -A 2

Repository: blinklabs-io/cardano-node-api

Length of output: 529


🏁 Script executed:

# Search for the Output type and Assets method definition in available code or comments
rg "Output.*interface|GetAssets|Assets\(\)" . --type go -B 2 -A 5 | head -50

Repository: blinklabs-io/cardano-node-api

Length of output: 1266


🏁 Script executed:

# Check the gouroboros package information from imports and usage
rg "localstatequery\.UTxOsResult\|ledger\." internal/api/localstatequery.go | head -20

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Let's understand what the Assets field should contain by looking at Cardano specifications
# Search for any mapping or conversion of assets in the codebase
rg "asset.*map\|AssetName\|PolicyID" internal/ -B 2 -A 2 | head -50

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Get the full context of assets usage in the handler to understand the type better
sed -n '490,520p' internal/api/localstatequery.go

Repository: blinklabs-io/cardano-node-api

Length of output: 597


🏁 Script executed:

# Look for where assets variable is used to understand its type
rg "assets\." internal/api/localstatequery.go -B 1 -A 1

Repository: blinklabs-io/cardano-node-api

Length of output: 180


🏁 Script executed:

# Check if there's documentation or type hints about what Assets() returns
rg "// Assets|assets interface|Assets interface" . --type go

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Final check - let's see what gets assigned to the Assets field in the response
rg "Assets:" internal/api/localstatequery.go -B 3 -A 1

Repository: blinklabs-io/cardano-node-api

Length of output: 201


Convert Assets interface{} to an explicit, concrete type with a defined JSON structure. Since Assets comes from a third-party library type without a guaranteed JSON format, assign it to a concrete struct (e.g., map[string]map[string]uint64 for {policy_id: {asset_name: quantity}}) or define a custom struct that explicitly represents the asset structure. Update the OpenAPI definition to document the response schema. This ensures API stability and allows clients to depend on a predictable contract.

// handleLocalStateQuerySearchUTxOsByAsset godoc
//
// @Summary Search UTxOs by Asset
// @Tags localstatequery
// @Produce json
// @Param policy_id query string true "Policy ID (hex)"
// @Param asset_name query string true "Asset name (hex)"
// @Param address query string false "Optional: Filter by address"
// @Success 200 {object} responseLocalStateQuerySearchUTxOsByAsset
// @Failure 400 {object} responseApiError
// @Failure 500 {object} responseApiError
// @Router /localstatequery/utxos/search-by-asset [get]
func handleLocalStateQuerySearchUTxOsByAsset(c *gin.Context) {
// Get query parameters
policyIdHex := c.Query("policy_id")
assetNameHex := c.Query("asset_name")
addressStr := c.Query("address")

// Validate required parameters
if policyIdHex == "" {
c.JSON(400, apiError("policy_id parameter is required"))
return
}
if assetNameHex == "" {
c.JSON(400, apiError("asset_name parameter is required"))
return
}

// Parse policy ID (28 bytes)
policyIdBytes, err := hex.DecodeString(policyIdHex)
if err != nil {
c.JSON(400, apiError("invalid policy_id hex: "+err.Error()))
return
}
if len(policyIdBytes) != 28 {
c.JSON(400, apiError("policy_id must be 28 bytes"))
return
}
var policyId ledger.Blake2b224
copy(policyId[:], policyIdBytes)

// Parse asset name
assetName, err := hex.DecodeString(assetNameHex)
if err != nil {
c.JSON(400, apiError("invalid asset_name hex: "+err.Error()))
return
}

// Parse optional address
var addrs []ledger.Address
if addressStr != "" {
addr, err := ledger.NewAddress(addressStr)
if err != nil {
c.JSON(400, apiError("invalid address: "+err.Error()))
return
}
addrs = append(addrs, addr)
}
Comment on lines +405 to +450
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate asset_name length (and consider upper-bounding query sizes).
Cardano asset names are bounded (commonly max 32 bytes). Without a length check, a large hex string can force large allocations during decode and later filtering.
Proposed patch:

  // Parse asset name
  assetName, err := hex.DecodeString(assetNameHex)
  if err != nil {
    c.JSON(400, apiError("invalid asset_name hex: "+err.Error()))
    return
  }
+ if len(assetName) > 32 {
+   c.JSON(400, apiError("asset_name must be <= 32 bytes"))
+   return
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func handleLocalStateQuerySearchUTxOsByAsset(c *gin.Context) {
// Get query parameters
policyIdHex := c.Query("policy_id")
assetNameHex := c.Query("asset_name")
addressStr := c.Query("address")
// Validate required parameters
if policyIdHex == "" {
c.JSON(400, apiError("policy_id parameter is required"))
return
}
if assetNameHex == "" {
c.JSON(400, apiError("asset_name parameter is required"))
return
}
// Parse policy ID (28 bytes)
policyIdBytes, err := hex.DecodeString(policyIdHex)
if err != nil {
c.JSON(400, apiError("invalid policy_id hex: "+err.Error()))
return
}
if len(policyIdBytes) != 28 {
c.JSON(400, apiError("policy_id must be 28 bytes"))
return
}
var policyId ledger.Blake2b224
copy(policyId[:], policyIdBytes)
// Parse asset name
assetName, err := hex.DecodeString(assetNameHex)
if err != nil {
c.JSON(400, apiError("invalid asset_name hex: "+err.Error()))
return
}
// Parse optional address
var addrs []ledger.Address
if addressStr != "" {
addr, err := ledger.NewAddress(addressStr)
if err != nil {
c.JSON(400, apiError("invalid address: "+err.Error()))
return
}
addrs = append(addrs, addr)
}
func handleLocalStateQuerySearchUTxOsByAsset(c *gin.Context) {
// Get query parameters
policyIdHex := c.Query("policy_id")
assetNameHex := c.Query("asset_name")
addressStr := c.Query("address")
// Validate required parameters
if policyIdHex == "" {
c.JSON(400, apiError("policy_id parameter is required"))
return
}
if assetNameHex == "" {
c.JSON(400, apiError("asset_name parameter is required"))
return
}
// Parse policy ID (28 bytes)
policyIdBytes, err := hex.DecodeString(policyIdHex)
if err != nil {
c.JSON(400, apiError("invalid policy_id hex: "+err.Error()))
return
}
if len(policyIdBytes) != 28 {
c.JSON(400, apiError("policy_id must be 28 bytes"))
return
}
var policyId ledger.Blake2b224
copy(policyId[:], policyIdBytes)
// Parse asset name
assetName, err := hex.DecodeString(assetNameHex)
if err != nil {
c.JSON(400, apiError("invalid asset_name hex: "+err.Error()))
return
}
if len(assetName) > 32 {
c.JSON(400, apiError("asset_name must be <= 32 bytes"))
return
}
// Parse optional address
var addrs []ledger.Address
if addressStr != "" {
addr, err := ledger.NewAddress(addressStr)
if err != nil {
c.JSON(400, apiError("invalid address: "+err.Error()))
return
}
addrs = append(addrs, addr)
}
🤖 Prompt for AI Agents
In internal/api/localstatequery.go around lines 405 to 450, the handler
currently decodes asset_name hex without checking length which can lead to large
allocations and unexpected inputs; before decoding, validate the hex string
length and resulting byte length (e.g. ensure the hex string is even-length and
the decoded asset name is <= 32 bytes, or a configured MAX_ASSET_NAME_BYTES) and
return a 400 error if it exceeds the limit; additionally, enforce a reasonable
upper bound on query parameter sizes (reject overly long
policy_id/asset_name/address strings) to prevent abuse and resource exhaustion.


// Connect to node
oConn, err := node.GetConnection(nil)
if err != nil {
c.JSON(500, apiError(err.Error()))
return
}
// Async error handler
go func() {
err, ok := <-oConn.ErrorChan()
if !ok {
return
}
c.JSON(500, apiError(err.Error()))
}()
defer func() {
// Close Ouroboros connection
oConn.Close()
}()
// Start client
oConn.LocalStateQuery().Client.Start()

// Get UTxOs (either by address or whole set)
var utxos *localstatequery.UTxOsResult
if len(addrs) > 0 {
utxos, err = oConn.LocalStateQuery().Client.GetUTxOByAddress(addrs)
} else {
utxos, err = oConn.LocalStateQuery().Client.GetUTxOWhole()
}
if err != nil {
c.JSON(500, apiError(err.Error()))
return
}
Comment on lines +452 to +483
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Get the full function context
cd internal/api && head -n 500 localstatequery.go | tail -n +420 | cat -n

Repository: blinklabs-io/cardano-node-api

Length of output: 2552


🏁 Script executed:

# Search for other instances of ErrorChan() patterns in the codebase
rg "ErrorChan()" -A 5 -B 2

Repository: blinklabs-io/cardano-node-api

Length of output: 4894


🏁 Script executed:

# Look for the function signature to understand the full context
rg -A 50 "func.*addrs.*\(" internal/api/localstatequery.go | head -100

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Look for gin.Context definition or usage patterns in the codebase
rg "gin\." internal/api/*.go | head -20

Repository: blinklabs-io/cardano-node-api

Length of output: 1596


🏁 Script executed:

# Search for any response status/write guards in the codebase
rg "Written\|HeadersWritten\|Status" internal/api/*.go | head -20

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🏁 Script executed:

# Check if gin.ResponseWriter has synchronization
rg "sync\|mutex\|lock" internal/api/*.go | grep -i "response\|writer" | head -10

Repository: blinklabs-io/cardano-node-api

Length of output: 55


🌐 Web query:

gin-gonic gin.Context thread safety concurrent writes JSON

💡 Result:

Short answer: gin.Context is not safe for concurrent writes. Don’t call c.JSON / c.Writer methods from multiple goroutines simultaneously — use c.Copy() when starting goroutines and avoid writing to the ResponseWriter after the handler returns. Instead marshal/prepare the response (or synchronize with a mutex) and write once from the handler, or extract needed data and pass plain values/context.Context to background goroutines.

Key references:

  • Gin docs: “When starting new Goroutines ... you SHOULD NOT use the original context inside it, you have to use a read-only copy” (use c.Copy()). [1]
  • pkg.go.dev: Context.Copy() documentation: “Copy returns a copy of the current context that can be safely used outside the request's scope.” [2]
  • Guidance & examples + community issues showing races/overwrites and cancellation pitfalls (don’t rely on *gin.Context for long-running background work; clone request context if you need no cancellation). [3]

🏁 Script executed:

# Verify the specific instance in localstatequery.go around lines 452-483
sed -n '445,490p' internal/api/localstatequery.go | cat -n

Repository: blinklabs-io/cardano-node-api

Length of output: 1410


Don't write to gin.Context from the async ErrorChan() goroutine—this causes race conditions and duplicate response writes.

When the handler returns (e.g., after calling c.JSON() at line 481) and the goroutine later attempts c.JSON(), you get "headers already written" errors or data races on the response. This pattern is repeated across multiple handlers (chainsync.go, localtxmonitor.go, etc.).

Use c.Copy() if passing context to goroutines, or better yet, avoid writing responses from background goroutines. Instead, log the error and close the connection, or signal the error through a channel to let the main handler decide the response.


// Filter UTxOs by asset
results := make([]utxoItem, 0)
for utxoId, output := range utxos.Results {
// Check if output has assets
assets := output.Assets()
if assets == nil {
continue
}

// Check if the asset exists in this UTxO
amount := assets.Asset(policyId, assetName)
if amount > 0 {
item := utxoItem{
TxHash: hex.EncodeToString(utxoId.Hash[:]),
Index: uint32(utxoId.Idx),

Check failure on line 499 in internal/api/localstatequery.go

View workflow job for this annotation

GitHub Actions / lint

G115: integer overflow conversion int -> uint32 (gosec)
Address: output.Address().String(),
Amount: output.Amount(),
Assets: assets,
}
results = append(results, item)
}
}

// Create response
resp := responseLocalStateQuerySearchUTxOsByAsset{
UTxOs: results,
Count: len(results),
}
c.JSON(200, resp)
}
Comment on lines +473 to +514
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Blocker: fix gosec G115 overflow on uint32(utxoId.Idx) (CI failing).
Add a bounds check before converting:

      if amount > 0 {
+       if utxoId.Idx < 0 || utxoId.Idx > math.MaxUint32 {
+         c.JSON(500, apiError("utxo index out of range"))
+         return
+       }
        item := utxoItem{
          TxHash:  hex.EncodeToString(utxoId.Hash[:]),
-         Index:   uint32(utxoId.Idx),
+         Index:   uint32(utxoId.Idx),
          Address: output.Address().String(),
          Amount:  output.Amount(),
          Assets:  assets,
        }

Also: fetching GetUTxOWhole() when address is omitted is a likely DoS footgun (latency/memory). At minimum, consider adding server-side guardrails (require address, or add pagination/limit + timeouts).

🧰 Tools
🪛 GitHub Actions: golangci-lint

[error] 499-499: G115: integer overflow conversion int -> uint32 (gosec)

🪛 GitHub Check: lint

[failure] 499-499:
G115: integer overflow conversion int -> uint32 (gosec)

🤖 Prompt for AI Agents
In internal/api/localstatequery.go around lines 473-514, add a bounds check
before converting utxoId.Idx to uint32 to fix the gosec G115 overflow: if
utxoId.Idx > math.MaxUint32 either skip that UTxO or return a 400/500 error as
appropriate instead of blindly casting. Additionally, remove or guard the
expensive GetUTxOWhole() path when len(addrs)==0: require an address parameter
(return 400 if missing) or implement server-side guardrails such as a max
limit/pagination (enforce a capped limit on results and support offset/page
token) and wrap the LocalStateQuery client calls with a context timeout to avoid
DoS/latency issues. Ensure any new imports (math, context) are added and
behavior is well-documented in the handler comment.

Loading