Skip to content
Merged
Show file tree
Hide file tree
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 @@ import (

"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 @@ func configureLocalStateQueryRoutes(apiGroup *gin.RouterGroup) {
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 @@ func handleLocalStateQueryGenesisConfig(c *gin.Context) {
//}
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"`
}

// 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)
}

// 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
}

// 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), // #nosec G115
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)
}
58 changes: 39 additions & 19 deletions internal/utxorpc/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"log"

connect "connectrpc.com/connect"
"github.com/blinklabs-io/cardano-node-api/internal/node"
"github.com/blinklabs-io/gouroboros/ledger"
"github.com/blinklabs-io/gouroboros/ledger/common"
"github.com/blinklabs-io/gouroboros/protocol/localstatequery"
query "github.com/utxorpc/go-codegen/utxorpc/v1alpha/query"
"github.com/utxorpc/go-codegen/utxorpc/v1alpha/query/queryconnect"
)
Expand Down Expand Up @@ -199,8 +201,29 @@ func (s *queryServiceServer) SearchUtxos(
addressPattern := predicate.GetMatch().GetCardano().GetAddress()
assetPattern := predicate.GetMatch().GetCardano().GetAsset()

var addresses []common.Address
// A Match can only contain EITHER addressPattern OR assetPattern, not both
if addressPattern != nil && assetPattern != nil {
return nil, errors.New(
"ERROR: Match cannot contain both address and asset patterns. Use AllOf predicate to combine them",
)
}

// Connect to node
oConn, err := node.GetConnection(nil)
if err != nil {
return nil, err
}
defer func() {
oConn.Close()
}()
oConn.LocalStateQuery().Client.Start()

var utxos *localstatequery.UTxOsResult

// Handle address-only search
if addressPattern != nil {
var addresses []common.Address

// Handle Exact Address
exactAddressBytes := addressPattern.GetExactAddress()
if exactAddressBytes != nil {
Expand Down Expand Up @@ -241,25 +264,22 @@ func (s *queryServiceServer) SearchUtxos(
}
addresses = append(addresses, delegationAddr)
}
}

// Connect to node
oConn, err := node.GetConnection(nil)
if err != nil {
return nil, err
}
defer func() {
// Close Ouroboros connection
oConn.Close()
}()
// Start client
oConn.LocalStateQuery().Client.Start()

// Get UTxOs
utxos, err := oConn.LocalStateQuery().Client.GetUTxOByAddress(addresses)
if err != nil {
log.Printf("ERROR: %s", err)
return nil, err
// Get UTxOs by address
utxos, err = oConn.LocalStateQuery().Client.GetUTxOByAddress(addresses)
if err != nil {
log.Printf("ERROR: %s", err)
return nil, err
}
} else if assetPattern != nil {
// Handle asset-only search - get all UTxOs and filter by asset
utxos, err = oConn.LocalStateQuery().Client.GetUTxOWhole()
if err != nil {
log.Printf("ERROR: %s", err)
return nil, err
}
Comment on lines +274 to +280
Copy link

@coderabbitai coderabbitai bot Jan 6, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Potential DoS: GetUTxOWhole() fetches the entire UTxO set.

On mainnet, the UTxO set contains tens of millions of entries. Without an address filter, this call can exhaust memory and cause significant latency. The previous review flagged this exact concern. You must:

  1. Require an address filter for asset searches (return error if not provided), or
  2. Add server-side pagination/limits, or
  3. Wrap the call with a context timeout

As implemented, this endpoint can be trivially exploited to cause service degradation.

🔎 Recommended fix: Require address for asset searches
 } else if assetPattern != nil {
-	// Handle asset-only search - get all UTxOs and filter by asset
-	utxos, err = oConn.LocalStateQuery().Client.GetUTxOWhole()
-	if err != nil {
-		log.Printf("ERROR: %s", err)
-		return nil, err
-	}
+	// Asset-only search without address is not supported due to DoS risk
+	return nil, errors.New(
+		"ERROR: Asset search requires an address filter. Use AllOf predicate to combine address and asset patterns",
+	)
📝 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
} else if assetPattern != nil {
// Handle asset-only search - get all UTxOs and filter by asset
utxos, err = oConn.LocalStateQuery().Client.GetUTxOWhole()
if err != nil {
log.Printf("ERROR: %s", err)
return nil, err
}
} else if assetPattern != nil {
// Asset-only search without address is not supported due to DoS risk
return nil, errors.New(
"ERROR: Asset search requires an address filter. Use AllOf predicate to combine address and asset patterns",
)

Copy link
Member

Choose a reason for hiding this comment

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

There is no server side pagination in the data from the Cardano node. There's no way to search for an asset when the owner address is unknown without the UTxO whole set. This is a known limitation of the Haskell cardano-node and the current capabilities of the ouroboros protocols.

Copy link

Choose a reason for hiding this comment

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

@wolf31o2, thank you for the clarification. I understand that this is an inherent limitation of the Cardano node's current protocol capabilities, and fetching the entire UTxO set is unavoidable for asset searches without a known owner address.

Given this constraint, I'd recommend considering API-level protective measures:

  1. Rate limiting on this endpoint to prevent abuse
  2. Documentation clearly stating the performance implications and resource requirements
  3. Response timeout configuration to prevent indefinite hangs
  4. Monitoring/alerting on endpoint usage and response times

These won't eliminate the risk but can help manage it at the application layer. The implementation is appropriate given the protocol limitations you've described.


✏️ Learnings added
Learnt from: wolf31o2
Repo: blinklabs-io/cardano-node-api PR: 510
File: internal/utxorpc/query.go:274-280
Timestamp: 2026-01-06T14:00:43.235Z
Learning: The Cardano node's ouroboros protocols do not support server-side pagination or filtered queries for asset searches. When searching for UTxOs by asset without a known owner address, fetching the entire UTxO set via GetUTxOWhole() is unavoidable due to limitations in the Haskell cardano-node.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

} else {
return nil, errors.New("ERROR: Match must contain either address or asset pattern")
}

// Get chain point (slot and hash)
Expand Down