Skip to content

Commit d0dfe13

Browse files
committed
endpoint for querying token holders
1 parent b2e8ea6 commit d0dfe13

File tree

3 files changed

+120
-2
lines changed

3 files changed

+120
-2
lines changed

cmd/api.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ func RunApi(cmd *cobra.Command, args []string) {
8686

8787
// token balance queries
8888
root.GET("/balances/:owner/:type", handlers.GetTokenBalancesByType)
89+
90+
// token holder queries
91+
root.GET("/holders/:address", handlers.GetTokenHoldersByType)
8992
}
9093

9194
r.GET("/health", func(c *gin.Context) {

internal/handlers/token_handlers.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ type BalanceModel struct {
1919
Balance *big.Int `json:"balance" ch:"balance"`
2020
}
2121

22+
type HolderModel struct {
23+
HolderAddress string `json:"holder_address" ch:"owner"`
24+
TokenId string `json:"token_id" ch:"token_id"`
25+
Balance *big.Int `json:"balance" ch:"balance"`
26+
}
27+
2228
// @Summary Get token balances of an address by type
2329
// @Description Retrieve token balances of an address by type
2430
// @Tags balances
@@ -125,3 +131,106 @@ func serializeBalance(balance common.TokenBalance) BalanceModel {
125131
}(),
126132
}
127133
}
134+
135+
// @Summary Get holders of a token
136+
// @Description Retrieve holders of a token
137+
// @Tags holders
138+
// @Accept json
139+
// @Produce json
140+
// @Security BasicAuth
141+
// @Param chainId path string true "Chain ID"
142+
// @Param address path string true "Address of the token"
143+
// @Param token_type path string false "Type of token"
144+
// @Param hide_zero_balances query bool true "Hide zero balances"
145+
// @Param page query int false "Page number for pagination"
146+
// @Param limit query int false "Number of items per page" default(5)
147+
// @Success 200 {object} api.QueryResponse{data=[]LogModel}
148+
// @Failure 400 {object} api.Error
149+
// @Failure 401 {object} api.Error
150+
// @Failure 500 {object} api.Error
151+
// @Router /{chainId}/holders/{address} [get]
152+
func GetTokenHoldersByType(c *gin.Context) {
153+
chainId, err := api.GetChainId(c)
154+
if err != nil {
155+
api.BadRequestErrorHandler(c, err)
156+
return
157+
}
158+
159+
address := strings.ToLower(c.Param("address"))
160+
if !strings.HasPrefix(address, "0x") {
161+
api.BadRequestErrorHandler(c, fmt.Errorf("invalid address '%s'", address))
162+
return
163+
}
164+
165+
tokenType := c.Query("token_type")
166+
if tokenType != "" && tokenType != "erc20" && tokenType != "erc1155" && tokenType != "erc721" {
167+
api.BadRequestErrorHandler(c, fmt.Errorf("invalid token type '%s'", tokenType))
168+
return
169+
}
170+
hideZeroBalances := c.Query("hide_zero_balances") != "false"
171+
172+
columns := []string{"owner", "sum(balance) as balance"}
173+
groupBy := []string{"owner"}
174+
if tokenType != "erc20" {
175+
columns = []string{"owner", "token_id", "sum(balance) as balance"}
176+
groupBy = []string{"owner", "token_id"}
177+
}
178+
179+
qf := storage.BalancesQueryFilter{
180+
ChainId: chainId,
181+
TokenType: tokenType,
182+
TokenAddress: address,
183+
ZeroBalance: hideZeroBalances,
184+
GroupBy: groupBy,
185+
SortBy: c.Query("sort_by"),
186+
SortOrder: c.Query("sort_order"),
187+
Page: api.ParseIntQueryParam(c.Query("page"), 0),
188+
Limit: api.ParseIntQueryParam(c.Query("limit"), 0),
189+
}
190+
191+
queryResult := api.QueryResponse{
192+
Meta: api.Meta{
193+
ChainId: chainId.Uint64(),
194+
Page: qf.Page,
195+
Limit: qf.Limit,
196+
},
197+
}
198+
199+
mainStorage, err = getMainStorage()
200+
if err != nil {
201+
log.Error().Err(err).Msg("Error getting main storage")
202+
api.InternalErrorHandler(c)
203+
return
204+
}
205+
206+
balancesResult, err := mainStorage.GetTokenBalances(qf, columns...)
207+
if err != nil {
208+
log.Error().Err(err).Msg("Error querying balances")
209+
// TODO: might want to choose BadRequestError if it's due to not-allowed functions
210+
api.InternalErrorHandler(c)
211+
return
212+
}
213+
queryResult.Data = serializeHolders(balancesResult.Data)
214+
sendJSONResponse(c, queryResult)
215+
}
216+
217+
func serializeHolders(holders []common.TokenBalance) []HolderModel {
218+
holderModels := make([]HolderModel, len(holders))
219+
for i, holder := range holders {
220+
holderModels[i] = serializeHolder(holder)
221+
}
222+
return holderModels
223+
}
224+
225+
func serializeHolder(holder common.TokenBalance) HolderModel {
226+
return HolderModel{
227+
HolderAddress: holder.Owner,
228+
Balance: holder.Balance,
229+
TokenId: func() string {
230+
if holder.TokenId != nil {
231+
return holder.TokenId.String()
232+
}
233+
return ""
234+
}(),
235+
}
236+
}

internal/storage/clickhouse.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1378,8 +1378,14 @@ func (c *ClickHouseConnector) GetTokenBalances(qf BalancesQueryFilter, fields ..
13781378
if len(fields) > 0 {
13791379
columns = strings.Join(fields, ", ")
13801380
}
1381-
query := fmt.Sprintf("SELECT %s FROM %s.token_balances WHERE chain_id = ? AND token_type = ? AND owner = ?", columns, c.cfg.Database)
1381+
query := fmt.Sprintf("SELECT %s FROM %s.token_balances WHERE chain_id = ?", columns, c.cfg.Database)
13821382

1383+
if qf.TokenType != "" {
1384+
query += fmt.Sprintf(" AND token_type = '%s'", qf.TokenType)
1385+
}
1386+
if qf.Owner != "" {
1387+
query += fmt.Sprintf(" AND owner = '%s'", qf.Owner)
1388+
}
13831389
if qf.TokenAddress != "" {
13841390
query += fmt.Sprintf(" AND address = '%s'", qf.TokenAddress)
13851391
}
@@ -1420,7 +1426,7 @@ func (c *ClickHouseConnector) GetTokenBalances(qf BalancesQueryFilter, fields ..
14201426
query += fmt.Sprintf(" LIMIT %d", qf.Limit)
14211427
}
14221428

1423-
rows, err := c.conn.Query(context.Background(), query, qf.ChainId, qf.TokenType, qf.Owner)
1429+
rows, err := c.conn.Query(context.Background(), query, qf.ChainId)
14241430
if err != nil {
14251431
return QueryResult[common.TokenBalance]{}, err
14261432
}

0 commit comments

Comments
 (0)