From eead2b83f73a7350c4eb208db34b6c45f86df66a Mon Sep 17 00:00:00 2001 From: Jenita Date: Thu, 11 Dec 2025 18:12:41 -0600 Subject: [PATCH 1/2] feat(utxorpc): changes to add support for searchUtxoByAsset Signed-off-by: Jenita --- internal/api/localstatequery.go | 138 ++++++++++++++++++++++++++++++++ internal/utxorpc/query.go | 57 ++++++++----- 2 files changed, 176 insertions(+), 19 deletions(-) diff --git a/internal/api/localstatequery.go b/internal/api/localstatequery.go index 39c8aa2..bbb36bd 100644 --- a/internal/api/localstatequery.go +++ b/internal/api/localstatequery.go @@ -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" ) @@ -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) @@ -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), + 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) +} diff --git a/internal/utxorpc/query.go b/internal/utxorpc/query.go index b742149..b21a41b 100644 --- a/internal/utxorpc/query.go +++ b/internal/utxorpc/query.go @@ -25,6 +25,7 @@ import ( "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" ) @@ -199,8 +200,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, fmt.Errorf( + "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 { @@ -241,25 +263,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 + } + } else { + return nil, fmt.Errorf("ERROR: Match must contain either address or asset pattern") } // Get chain point (slot and hash) From 4735d864917791b71cc0fdb84c7f3244279b1d86 Mon Sep 17 00:00:00 2001 From: Jenita Date: Mon, 5 Jan 2026 21:02:53 -0600 Subject: [PATCH 2/2] feat(utxorpc): changes to add support for searchUtxoByAsset Signed-off-by: Jenita --- internal/api/localstatequery.go | 2 +- internal/utxorpc/query.go | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/api/localstatequery.go b/internal/api/localstatequery.go index bbb36bd..4eabe44 100644 --- a/internal/api/localstatequery.go +++ b/internal/api/localstatequery.go @@ -496,7 +496,7 @@ func handleLocalStateQuerySearchUTxOsByAsset(c *gin.Context) { if amount > 0 { item := utxoItem{ TxHash: hex.EncodeToString(utxoId.Hash[:]), - Index: uint32(utxoId.Idx), + Index: uint32(utxoId.Idx), // #nosec G115 Address: output.Address().String(), Amount: output.Amount(), Assets: assets, diff --git a/internal/utxorpc/query.go b/internal/utxorpc/query.go index b21a41b..173b1cb 100644 --- a/internal/utxorpc/query.go +++ b/internal/utxorpc/query.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "encoding/hex" + "errors" "fmt" "log" @@ -202,7 +203,7 @@ func (s *queryServiceServer) SearchUtxos( // A Match can only contain EITHER addressPattern OR assetPattern, not both if addressPattern != nil && assetPattern != nil { - return nil, fmt.Errorf( + return nil, errors.New( "ERROR: Match cannot contain both address and asset patterns. Use AllOf predicate to combine them", ) } @@ -278,7 +279,7 @@ func (s *queryServiceServer) SearchUtxos( return nil, err } } else { - return nil, fmt.Errorf("ERROR: Match must contain either address or asset pattern") + return nil, errors.New("ERROR: Match must contain either address or asset pattern") } // Get chain point (slot and hash)