Skip to content

Commit 095b23c

Browse files
committed
create an endpoint to query token balances
1 parent f2005cc commit 095b23c

File tree

8 files changed

+350
-0
lines changed

8 files changed

+350
-0
lines changed

api/api.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,14 @@ func GetChainId(c *gin.Context) (*big.Int, error) {
139139
}
140140
return big.NewInt(int64(chainIdInt)), nil
141141
}
142+
143+
func ParseIntQueryParam(value string, defaultValue int) int {
144+
if value == "" {
145+
return defaultValue
146+
}
147+
parsed, err := strconv.Atoi(value)
148+
if err != nil {
149+
return defaultValue
150+
}
151+
return parsed
152+
}

cmd/api.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ func RunApi(cmd *cobra.Command, args []string) {
7373
// signature scoped queries
7474
root.GET("/transactions/:to/:signature", handlers.GetTransactionsByContractAndSignature)
7575
root.GET("/events/:contract/:signature", handlers.GetLogsByContractAndSignature)
76+
77+
root.GET("/balances/:owner/:type", handlers.GetTokenBalancesByType)
7678
}
7779

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

internal/common/balances.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package common
2+
3+
import (
4+
"math/big"
5+
)
6+
7+
type TokenBalance struct {
8+
ChainId *big.Int `json:"chain_id" ch:"chain_id"`
9+
TokenType string `json:"token_type" ch:"token_type"`
10+
TokenAddress string `json:"token_address" ch:"address"`
11+
Owner string `json:"owner" ch:"owner"`
12+
TokenId *big.Int `json:"token_id" ch:"token_id"`
13+
Balance *big.Int `json:"balance" ch:"balance"`
14+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package handlers
2+
3+
import (
4+
"fmt"
5+
"math/big"
6+
"strings"
7+
8+
"github.com/gin-gonic/gin"
9+
"github.com/rs/zerolog/log"
10+
"github.com/thirdweb-dev/indexer/api"
11+
"github.com/thirdweb-dev/indexer/internal/storage"
12+
)
13+
14+
// BalanceModel return type for Swagger documentation
15+
type BalanceModel struct {
16+
ChainId string `json:"chain_id" ch:"chain_id"`
17+
TokenType string `json:"token_type" ch:"token_type"`
18+
TokenAddress string `json:"token_address" ch:"address"`
19+
Owner string `json:"owner" ch:"owner"`
20+
TokenId string `json:"token_id" ch:"token_id"`
21+
Balance *big.Int `json:"balance" ch:"balance"`
22+
}
23+
24+
// @Summary Get token balances of an address by type
25+
// @Description Retrieve token balances of an address by type
26+
// @Tags balances
27+
// @Accept json
28+
// @Produce json
29+
// @Security BasicAuth
30+
// @Param chainId path string true "Chain ID"
31+
// @Param owner path string true "Owner address"
32+
// @Param type path string true "Type of token balance"
33+
// @Param hide_zero_balances query bool true "Hide zero balances"
34+
// @Param page query int false "Page number for pagination"
35+
// @Param limit query int false "Number of items per page" default(5)
36+
// @Success 200 {object} api.QueryResponse{data=[]LogModel}
37+
// @Failure 400 {object} api.Error
38+
// @Failure 401 {object} api.Error
39+
// @Failure 500 {object} api.Error
40+
// @Router /{chainId}/events [get]
41+
func GetTokenBalancesByType(c *gin.Context) {
42+
chainId, err := api.GetChainId(c)
43+
if err != nil {
44+
api.BadRequestErrorHandler(c, err)
45+
return
46+
}
47+
tokenType := c.Param("type")
48+
if tokenType != "erc20" && tokenType != "erc1155" && tokenType != "erc721" {
49+
api.BadRequestErrorHandler(c, fmt.Errorf("invalid token type '%s'", tokenType))
50+
return
51+
}
52+
owner := strings.ToLower(c.Param("owner"))
53+
if !strings.HasPrefix(owner, "0x") {
54+
api.BadRequestErrorHandler(c, fmt.Errorf("invalid owner address '%s'", owner))
55+
return
56+
}
57+
tokenAddress := strings.ToLower(c.Query("token_address"))
58+
if tokenAddress != "" && !strings.HasPrefix(tokenAddress, "0x") {
59+
api.BadRequestErrorHandler(c, fmt.Errorf("invalid token address '%s'", tokenAddress))
60+
return
61+
}
62+
hideZeroBalances := c.Query("hide_zero_balances") != "false"
63+
qf := storage.BalancesQueryFilter{
64+
ChainId: chainId,
65+
Owner: owner,
66+
TokenType: tokenType,
67+
TokenAddress: tokenAddress,
68+
ZeroBalance: hideZeroBalances,
69+
SortBy: c.Query("sort_by"),
70+
SortOrder: c.Query("sort_order"),
71+
Page: api.ParseIntQueryParam(c.Query("page"), 0),
72+
Limit: api.ParseIntQueryParam(c.Query("limit"), 0),
73+
}
74+
75+
queryResult := api.QueryResponse{
76+
Meta: api.Meta{
77+
ChainId: chainId.Uint64(),
78+
Page: qf.Page,
79+
Limit: qf.Limit,
80+
},
81+
}
82+
83+
mainStorage, err = getMainStorage()
84+
if err != nil {
85+
log.Error().Err(err).Msg("Error getting main storage")
86+
api.InternalErrorHandler(c)
87+
return
88+
}
89+
90+
balancesResult, err := mainStorage.GetTokenBalances(qf)
91+
if err != nil {
92+
log.Error().Err(err).Msg("Error querying balances")
93+
// TODO: might want to choose BadRequestError if it's due to not-allowed functions
94+
api.InternalErrorHandler(c)
95+
return
96+
}
97+
queryResult.Data = balancesResult.Data
98+
sendJSONResponse(c, queryResult)
99+
}

internal/storage/clickhouse.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,3 +1322,50 @@ func getUnderlyingValue(valuePtr interface{}) interface{} {
13221322

13231323
return v.Interface()
13241324
}
1325+
1326+
func (c *ClickHouseConnector) GetTokenBalances(qf BalancesQueryFilter) (QueryResult[common.TokenBalance], error) {
1327+
columns := "chain_id, token_type, address, owner, token_id, balance"
1328+
balanceCondition := ">="
1329+
if qf.ZeroBalance {
1330+
balanceCondition = ">"
1331+
}
1332+
query := fmt.Sprintf("SELECT %s FROM %s.token_balances FINAL WHERE chain_id = ? AND token_type = ? AND owner = ? AND balance %s 0", columns, c.cfg.Database, balanceCondition)
1333+
1334+
if qf.TokenAddress != "" {
1335+
query += fmt.Sprintf(" AND address = '%s'", qf.TokenAddress)
1336+
}
1337+
1338+
// Add ORDER BY clause
1339+
if qf.SortBy != "" {
1340+
query += fmt.Sprintf(" ORDER BY %s %s", qf.SortBy, qf.SortOrder)
1341+
}
1342+
1343+
// Add limit clause
1344+
if qf.Page > 0 && qf.Limit > 0 {
1345+
offset := (qf.Page - 1) * qf.Limit
1346+
query += fmt.Sprintf(" LIMIT %d OFFSET %d", qf.Limit, offset)
1347+
} else if qf.Limit > 0 {
1348+
query += fmt.Sprintf(" LIMIT %d", qf.Limit)
1349+
}
1350+
1351+
rows, err := c.conn.Query(context.Background(), query, qf.ChainId, qf.TokenType, qf.Owner)
1352+
if err != nil {
1353+
return QueryResult[common.TokenBalance]{}, err
1354+
}
1355+
defer rows.Close()
1356+
1357+
queryResult := QueryResult[common.TokenBalance]{
1358+
Data: []common.TokenBalance{},
1359+
}
1360+
1361+
for rows.Next() {
1362+
var tb common.TokenBalance
1363+
err := rows.ScanStruct(&tb)
1364+
if err != nil {
1365+
return QueryResult[common.TokenBalance]{}, err
1366+
}
1367+
queryResult.Data = append(queryResult.Data, tb)
1368+
}
1369+
1370+
return queryResult, nil
1371+
}

internal/storage/connector.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,20 @@ type QueryFilter struct {
2323
ContractAddress string
2424
Signature string
2525
}
26+
27+
type BalancesQueryFilter struct {
28+
ChainId *big.Int
29+
TokenType string
30+
TokenAddress string
31+
Owner string
32+
ZeroBalance bool
33+
SortBy string
34+
SortOrder string
35+
Page int
36+
Limit int
37+
Offset int
38+
}
39+
2640
type QueryResult[T any] struct {
2741
// TODO: findout how to only allow Log/transaction arrays or split the result
2842
Data []T `json:"data"`
@@ -64,6 +78,8 @@ type IMainStorage interface {
6478
*/
6579
GetBlockHeadersDescending(chainId *big.Int, from *big.Int, to *big.Int) (blockHeaders []common.BlockHeader, err error)
6680
DeleteBlockData(chainId *big.Int, blockNumbers []*big.Int) error
81+
82+
GetTokenBalances(qf BalancesQueryFilter) (QueryResult[common.TokenBalance], error)
6783
}
6884

6985
func NewStorageConnector(cfg *config.StorageConfig) (IStorage, error) {
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
CREATE TABLE token_balances
2+
(
3+
`token_type` String,
4+
`chain_id` UInt256,
5+
`owner` FixedString(42),
6+
`address` FixedString(42),
7+
`token_id` UInt256,
8+
`balance` Int256
9+
)
10+
ENGINE = SummingMergeTree
11+
ORDER BY (token_type, chain_id, owner, address, token_id);
12+
13+
CREATE MATERIALIZED VIEW single_token_transfers_mv TO token_balances AS
14+
SELECT chain_id, owner, address, token_type, token_id, sum(amount) as balance
15+
FROM
16+
(
17+
SELECT
18+
chain_id,
19+
address,
20+
(topic_0 = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' AND topic_3 = '') as is_erc20,
21+
(topic_0 = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' AND topic_3 != '') as is_erc721,
22+
(topic_0 = '0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62') as is_erc1155,
23+
if(is_erc1155, concat('0x', substring(topic_2, 27, 40)), concat('0x', substring(topic_1, 27, 40))) AS sender_address,
24+
if(is_erc1155, concat('0x', substring(topic_3, 27, 40)), concat('0x', substring(topic_2, 27, 40))) AS receiver_address,
25+
multiIf(is_erc20, 'erc20', is_erc721, 'erc721', 'erc1155') as token_type,
26+
multiIf(
27+
is_erc1155,
28+
reinterpretAsUInt256(reverse(unhex(substring(data, 3, 64)))),
29+
is_erc721,
30+
reinterpretAsUInt256(reverse(unhex(substring(topic_3, 3, 64)))),
31+
toUInt256(0) -- other
32+
) AS token_id,
33+
multiIf(
34+
is_erc20 AND length(data) = 66,
35+
reinterpretAsInt256(reverse(unhex(substring(data, 3)))),
36+
is_erc721 OR is_erc1155,
37+
toInt256(1),
38+
toInt256(0) -- unknown
39+
) as transfer_amount,
40+
if(is_deleted = 0, transfer_amount, -transfer_amount) as amount
41+
FROM logs
42+
WHERE
43+
topic_0 IN (
44+
'0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
45+
'0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62'
46+
)
47+
)
48+
array join
49+
[chain_id, chain_id] AS chain_id,
50+
[sender_address, receiver_address] AS owner,
51+
[-amount, amount] as amount,
52+
[token_type, token_type] AS token_type,
53+
[token_id, token_id] AS token_id,
54+
[address, address] AS address
55+
GROUP BY chain_id, owner, address, token_type, token_id;
56+
57+
CREATE MATERIALIZED VIEW erc1155_batch_token_transfers_mv TO token_balances AS
58+
SELECT chain_id, owner, address, token_type, token_id, sum(amount) as balance
59+
FROM (
60+
WITH
61+
metadata as (
62+
SELECT
63+
*,
64+
3 + 2 * 64 as ids_length_idx,
65+
ids_length_idx + 64 as ids_values_idx,
66+
reinterpretAsUInt64(reverse(unhex(substring(data, ids_length_idx, 64)))) AS ids_length,
67+
ids_length_idx + 64 + (ids_length * 64) as amounts_length_idx,
68+
reinterpretAsUInt64(reverse(unhex(substring(data, amounts_length_idx, 64)))) AS amounts_length,
69+
amounts_length_idx + 64 as amounts_values_idx
70+
FROM logs
71+
WHERE topic_0 = '0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb'
72+
),
73+
decoded AS (
74+
SELECT
75+
*,
76+
arrayMap(
77+
x -> substring(data, ids_values_idx + (x - 1) * 64, 64),
78+
range(1, ids_length + 1)
79+
) AS ids_hex,
80+
arrayMap(
81+
x -> substring(data, amounts_values_idx + (x - 1) * 64, 64),
82+
range(1, amounts_length + 1)
83+
) AS amounts_hex
84+
FROM metadata
85+
)
86+
SELECT
87+
chain_id,
88+
address,
89+
concat('0x', substring(topic_2, 27, 40)) AS sender_address,
90+
concat('0x', substring(topic_3, 27, 40)) AS receiver_address,
91+
'erc1155' as token_type,
92+
reinterpretAsUInt256(reverse(unhex(substring(hex_id, 1, 64)))) AS token_id,
93+
reinterpretAsInt256(reverse(unhex(substring(hex_amount, 1, 64)))) AS transfer_amount,
94+
if(is_deleted = 0, transfer_amount, -transfer_amount) as amount
95+
FROM decoded
96+
ARRAY JOIN ids_hex AS hex_id, amounts_hex AS hex_amount
97+
)
98+
array join
99+
[chain_id, chain_id] AS chain_id,
100+
[sender_address, receiver_address] AS owner,
101+
[-amount, amount] as amount,
102+
[token_type, token_type] AS token_type,
103+
[token_id, token_id] AS token_id,
104+
[address, address] AS address
105+
GROUP BY chain_id, owner, address, token_type, token_id;

test/mocks/MockIMainStorage.go

Lines changed: 56 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)