diff --git a/cmd/api.go b/cmd/api.go index cdd24db..c557190 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -92,6 +92,9 @@ func RunApi(cmd *cobra.Command, args []string) { // token holder queries root.GET("/holders/:address", handlers.GetTokenHoldersByType) + // token ID queries + root.GET("/tokens/:address", handlers.GetTokenIdsByType) + // search root.GET("/search/:input", handlers.Search) } diff --git a/docs/docs.go b/docs/docs.go index 57bfd85..3f4ec74 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -747,7 +747,7 @@ const docTemplate = `{ "type": "string", "description": "Type of token", "name": "token_type", - "in": "path" + "in": "query" }, { "type": "boolean", @@ -813,6 +813,109 @@ const docTemplate = `{ } } }, + "/{chainId}/tokens/{address}": { + "get": { + "security": [ + { + "BasicAuth": [] + } + ], + "description": "Retrieve token IDs by type for a specific token address", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tokens" + ], + "summary": "Get token IDs by type for a specific token address", + "parameters": [ + { + "type": "string", + "description": "Chain ID", + "name": "chainId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Token address", + "name": "address", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Type of token (erc721 or erc1155)", + "name": "token_type", + "in": "query" + }, + { + "type": "boolean", + "description": "Hide zero balances", + "name": "hide_zero_balances", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Page number for pagination", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 5, + "description": "Number of items per page", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/api.QueryResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.TokenIdModel" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + } + } + }, "/{chainId}/transactions": { "get": { "security": [ @@ -1359,12 +1462,18 @@ const docTemplate = `{ "indexedParams": { "type": "object" }, + "indexed_params": { + "type": "object" + }, "name": { "type": "string" }, "nonIndexedParams": { "type": "object" }, + "non_indexed_params": { + "type": "object" + }, "signature": { "type": "string" } @@ -1678,6 +1787,9 @@ const docTemplate = `{ }, "token_id": { "type": "string" + }, + "token_type": { + "type": "string" } } }, @@ -1692,6 +1804,9 @@ const docTemplate = `{ }, "token_id": { "type": "string" + }, + "token_type": { + "type": "string" } } }, @@ -1739,6 +1854,17 @@ const docTemplate = `{ "SearchResultTypeAddress", "SearchResultTypeContract" ] + }, + "handlers.TokenIdModel": { + "type": "object", + "properties": { + "token_id": { + "type": "string" + }, + "token_type": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index 18efe29..5e8bb2e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -740,7 +740,7 @@ "type": "string", "description": "Type of token", "name": "token_type", - "in": "path" + "in": "query" }, { "type": "boolean", @@ -806,6 +806,109 @@ } } }, + "/{chainId}/tokens/{address}": { + "get": { + "security": [ + { + "BasicAuth": [] + } + ], + "description": "Retrieve token IDs by type for a specific token address", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tokens" + ], + "summary": "Get token IDs by type for a specific token address", + "parameters": [ + { + "type": "string", + "description": "Chain ID", + "name": "chainId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Token address", + "name": "address", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Type of token (erc721 or erc1155)", + "name": "token_type", + "in": "query" + }, + { + "type": "boolean", + "description": "Hide zero balances", + "name": "hide_zero_balances", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Page number for pagination", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 5, + "description": "Number of items per page", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/api.QueryResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.TokenIdModel" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + } + } + }, "/{chainId}/transactions": { "get": { "security": [ @@ -1352,12 +1455,18 @@ "indexedParams": { "type": "object" }, + "indexed_params": { + "type": "object" + }, "name": { "type": "string" }, "nonIndexedParams": { "type": "object" }, + "non_indexed_params": { + "type": "object" + }, "signature": { "type": "string" } @@ -1671,6 +1780,9 @@ }, "token_id": { "type": "string" + }, + "token_type": { + "type": "string" } } }, @@ -1685,6 +1797,9 @@ }, "token_id": { "type": "string" + }, + "token_type": { + "type": "string" } } }, @@ -1732,6 +1847,17 @@ "SearchResultTypeAddress", "SearchResultTypeContract" ] + }, + "handlers.TokenIdModel": { + "type": "object", + "properties": { + "token_id": { + "type": "string" + }, + "token_type": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 0a24e4f..0287367 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -103,10 +103,14 @@ definitions: type: object common.DecodedLogDataModel: properties: + indexed_params: + type: object indexedParams: type: object name: type: string + non_indexed_params: + type: object nonIndexedParams: type: object signature: @@ -314,6 +318,8 @@ definitions: type: string token_id: type: string + token_type: + type: string type: object handlers.HolderModel: properties: @@ -323,6 +329,8 @@ definitions: type: string token_id: type: string + token_type: + type: string type: object handlers.SearchResultModel: properties: @@ -357,6 +365,13 @@ definitions: - SearchResultTypeFunctionSignature - SearchResultTypeAddress - SearchResultTypeContract + handlers.TokenIdModel: + properties: + token_id: + type: string + token_type: + type: string + type: object info: contact: {} description: API for querying blockchain transactions and events @@ -778,7 +793,7 @@ paths: required: true type: string - description: Type of token - in: path + in: query name: token_type type: string - description: Hide zero balances @@ -826,6 +841,71 @@ paths: summary: Get holders of a token tags: - holders + /{chainId}/tokens/{address}: + get: + consumes: + - application/json + description: Retrieve token IDs by type for a specific token address + parameters: + - description: Chain ID + in: path + name: chainId + required: true + type: string + - description: Token address + in: path + name: address + required: true + type: string + - description: Type of token (erc721 or erc1155) + in: query + name: token_type + type: string + - description: Hide zero balances + in: query + name: hide_zero_balances + required: true + type: boolean + - description: Page number for pagination + in: query + name: page + type: integer + - default: 5 + description: Number of items per page + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/api.QueryResponse' + - properties: + data: + items: + $ref: '#/definitions/handlers.TokenIdModel' + type: array + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - BasicAuth: [] + summary: Get token IDs by type for a specific token address + tags: + - tokens /{chainId}/transactions: get: consumes: diff --git a/internal/handlers/token_handlers.go b/internal/handlers/token_handlers.go index 298bb12..3187061 100644 --- a/internal/handlers/token_handlers.go +++ b/internal/handlers/token_handlers.go @@ -12,7 +12,7 @@ import ( "github.com/thirdweb-dev/indexer/internal/storage" ) -// BalanceModel return type for Swagger documentation +// Models return type for Swagger documentation type BalanceModel struct { TokenAddress string `json:"token_address" ch:"address"` TokenId string `json:"token_id" ch:"token_id"` @@ -20,6 +20,11 @@ type BalanceModel struct { TokenType string `json:"token_type" ch:"token_type"` } +type TokenIdModel struct { + TokenId string `json:"token_id" ch:"token_id"` + TokenType string `json:"token_type" ch:"token_type"` +} + type HolderModel struct { HolderAddress string `json:"holder_address" ch:"owner"` TokenId string `json:"token_id" ch:"token_id"` @@ -27,6 +32,106 @@ type HolderModel struct { TokenType string `json:"token_type" ch:"token_type"` } +// @Summary Get token IDs by type for a specific token address +// @Description Retrieve token IDs by type for a specific token address +// @Tags tokens +// @Accept json +// @Produce json +// @Security BasicAuth +// @Param chainId path string true "Chain ID" +// @Param address path string true "Token address" +// @Param token_type query string false "Type of token (erc721 or erc1155)" +// @Param hide_zero_balances query bool true "Hide zero balances" +// @Param page query int false "Page number for pagination" +// @Param limit query int false "Number of items per page" default(5) +// @Success 200 {object} api.QueryResponse{data=[]TokenIdModel} +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 500 {object} api.Error +// @Router /{chainId}/tokens/{address} [get] +func GetTokenIdsByType(c *gin.Context) { + chainId, err := api.GetChainId(c) + if err != nil { + api.BadRequestErrorHandler(c, err) + return + } + + address := strings.ToLower(c.Param("address")) + if !strings.HasPrefix(address, "0x") { + api.BadRequestErrorHandler(c, fmt.Errorf("invalid token address '%s'", address)) + return + } + + tokenTypes, err := getTokenTypesFromReq(c) + if err != nil { + api.BadRequestErrorHandler(c, err) + return + } + + // Filter out erc20 tokens as they don't have token IDs + filteredTokenTypes := []string{} + for _, tokenType := range tokenTypes { + if tokenType == "erc721" || tokenType == "erc1155" { + filteredTokenTypes = append(filteredTokenTypes, tokenType) + } + } + + if len(filteredTokenTypes) == 0 { + // Default to both ERC721 and ERC1155 if no valid token types specified + filteredTokenTypes = []string{"erc721", "erc1155"} + } + + hideZeroBalances := c.Query("hide_zero_balances") != "false" + + // We only care about token_id and token_type + columns := []string{"token_id", "token_type"} + groupBy := []string{"token_id", "token_type"} + + tokenIds, err := getTokenIdsFromReq(c) + if err != nil { + api.BadRequestErrorHandler(c, fmt.Errorf("invalid token ids '%s'", err)) + return + } + + qf := storage.BalancesQueryFilter{ + ChainId: chainId, + TokenTypes: filteredTokenTypes, + TokenAddress: address, + ZeroBalance: hideZeroBalances, + TokenIds: tokenIds, + GroupBy: groupBy, + SortBy: c.Query("sort_by"), + SortOrder: c.Query("sort_order"), + Page: api.ParseIntQueryParam(c.Query("page"), 0), + Limit: api.ParseIntQueryParam(c.Query("limit"), 0), + } + + queryResult := api.QueryResponse{ + Meta: api.Meta{ + ChainId: chainId.Uint64(), + Page: qf.Page, + Limit: qf.Limit, + }, + } + + mainStorage, err = getMainStorage() + if err != nil { + log.Error().Err(err).Msg("Error getting main storage") + api.InternalErrorHandler(c) + return + } + + balancesResult, err := mainStorage.GetTokenBalances(qf, columns...) + if err != nil { + log.Error().Err(err).Msg("Error querying token IDs") + api.InternalErrorHandler(c) + return + } + + queryResult.Data = serializeTokenIds(balancesResult.Data) + sendJSONResponse(c, queryResult) +} + // @Summary Get token balances of an address by type // @Description Retrieve token balances of an address by type // @Tags balances @@ -123,65 +228,6 @@ func GetTokenBalancesByType(c *gin.Context) { sendJSONResponse(c, queryResult) } -func serializeBalances(balances []common.TokenBalance) []BalanceModel { - balanceModels := make([]BalanceModel, len(balances)) - for i, balance := range balances { - balanceModels[i] = serializeBalance(balance) - } - return balanceModels -} - -func serializeBalance(balance common.TokenBalance) BalanceModel { - return BalanceModel{ - TokenAddress: balance.TokenAddress, - Balance: balance.Balance.String(), - TokenId: func() string { - if balance.TokenId != nil { - return balance.TokenId.String() - } - return "" - }(), - TokenType: balance.TokenType, - } -} - -func getTokenTypesFromReq(c *gin.Context) ([]string, error) { - tokenTypeParam := c.Param("type") - var tokenTypes []string - if tokenTypeParam != "" { - tokenTypes = []string{tokenTypeParam} - } else { - tokenTypes = c.QueryArray("token_type") - } - - for i, tokenType := range tokenTypes { - tokenType = strings.ToLower(tokenType) - if tokenType != "erc721" && tokenType != "erc1155" && tokenType != "erc20" { - return []string{}, fmt.Errorf("invalid token type: %s", tokenType) - } - tokenTypes[i] = tokenType - } - return tokenTypes, nil -} - -func getTokenIdsFromReq(c *gin.Context) ([]*big.Int, error) { - tokenIds := c.QueryArray("token_id") - tokenIdsBn := make([]*big.Int, len(tokenIds)) - for i, tokenId := range tokenIds { - tokenId = strings.TrimSpace(tokenId) // Remove potential whitespace - if tokenId == "" { - return nil, fmt.Errorf("invalid token id: %s", tokenId) - } - num := new(big.Int) - _, ok := num.SetString(tokenId, 10) // Base 10 - if !ok { - return nil, fmt.Errorf("invalid token id: %s", tokenId) - } - tokenIdsBn[i] = num - } - return tokenIdsBn, nil -} - // @Summary Get holders of a token // @Description Retrieve holders of a token // @Tags holders @@ -190,7 +236,7 @@ func getTokenIdsFromReq(c *gin.Context) ([]*big.Int, error) { // @Security BasicAuth // @Param chainId path string true "Chain ID" // @Param address path string true "Address of the token" -// @Param token_type path string false "Type of token" +// @Param token_type query string false "Type of token" // @Param hide_zero_balances query bool true "Hide zero balances" // @Param page query int false "Page number for pagination" // @Param limit query int false "Number of items per page" default(5) @@ -271,6 +317,65 @@ func GetTokenHoldersByType(c *gin.Context) { sendJSONResponse(c, queryResult) } +func serializeBalances(balances []common.TokenBalance) []BalanceModel { + balanceModels := make([]BalanceModel, len(balances)) + for i, balance := range balances { + balanceModels[i] = serializeBalance(balance) + } + return balanceModels +} + +func serializeBalance(balance common.TokenBalance) BalanceModel { + return BalanceModel{ + TokenAddress: balance.TokenAddress, + Balance: balance.Balance.String(), + TokenId: func() string { + if balance.TokenId != nil { + return balance.TokenId.String() + } + return "" + }(), + TokenType: balance.TokenType, + } +} + +func getTokenTypesFromReq(c *gin.Context) ([]string, error) { + tokenTypeParam := c.Param("type") + var tokenTypes []string + if tokenTypeParam != "" { + tokenTypes = []string{tokenTypeParam} + } else { + tokenTypes = c.QueryArray("token_type") + } + + for i, tokenType := range tokenTypes { + tokenType = strings.ToLower(tokenType) + if tokenType != "erc721" && tokenType != "erc1155" && tokenType != "erc20" { + return []string{}, fmt.Errorf("invalid token type: %s", tokenType) + } + tokenTypes[i] = tokenType + } + return tokenTypes, nil +} + +func getTokenIdsFromReq(c *gin.Context) ([]*big.Int, error) { + tokenIds := c.QueryArray("token_id") + tokenIdsBn := make([]*big.Int, len(tokenIds)) + for i, tokenId := range tokenIds { + tokenId = strings.TrimSpace(tokenId) // Remove potential whitespace + if tokenId == "" { + return nil, fmt.Errorf("invalid token id: %s", tokenId) + } + num := new(big.Int) + _, ok := num.SetString(tokenId, 10) // Base 10 + if !ok { + return nil, fmt.Errorf("invalid token id: %s", tokenId) + } + tokenIdsBn[i] = num + } + return tokenIdsBn, nil +} + func serializeHolders(holders []common.TokenBalance) []HolderModel { holderModels := make([]HolderModel, len(holders)) for i, holder := range holders { @@ -292,3 +397,23 @@ func serializeHolder(holder common.TokenBalance) HolderModel { TokenType: holder.TokenType, } } + +func serializeTokenIds(balances []common.TokenBalance) []TokenIdModel { + tokenIdModels := make([]TokenIdModel, len(balances)) + for i, balance := range balances { + tokenIdModels[i] = serializeTokenId(balance) + } + return tokenIdModels +} + +func serializeTokenId(balance common.TokenBalance) TokenIdModel { + return TokenIdModel{ + TokenId: func() string { + if balance.TokenId != nil { + return balance.TokenId.String() + } + return "" + }(), + TokenType: balance.TokenType, + } +}