feat: add get Runes info batch api (#73)

* fix: make existing handlers use new total holders usecase

* fix: error msg

* feat: add get token info batch

* feat: add includeHoldersCount in get tokens api

* refactor: extract response mapping

* fix: rename new field and add holdersCount to extend

* fix: query params array

* fix: error msg

* fix: struct tags

* fix: remove error

* feat: add default value to additional fields
This commit is contained in:
gazenw
2024-10-31 14:14:58 +07:00
committed by GitHub
parent cffe378beb
commit c5c9a7bdeb
8 changed files with 246 additions and 121 deletions

View File

@@ -3,6 +3,7 @@ package httphandler
import ( import (
"bytes" "bytes"
"encoding/hex" "encoding/hex"
"fmt"
"net/url" "net/url"
"slices" "slices"
@@ -90,7 +91,7 @@ func (h *HttpHandler) GetHolders(ctx *fiber.Ctx) (err error) {
var ok bool var ok bool
runeId, ok = h.resolveRuneId(ctx.UserContext(), req.Id) runeId, ok = h.resolveRuneId(ctx.UserContext(), req.Id)
if !ok { if !ok {
return errs.NewPublicError("unable to resolve rune id from \"id\"") return errs.NewPublicError(fmt.Sprintf("unable to resolve rune id \"%s\" from \"id\"", req.Id))
} }
} }

View File

@@ -1,12 +1,12 @@
package httphandler package httphandler
import ( import (
"fmt"
"net/url" "net/url"
"slices" "strings"
"github.com/cockroachdb/errors" "github.com/cockroachdb/errors"
"github.com/gaze-network/indexer-network/common/errs" "github.com/gaze-network/indexer-network/common/errs"
"github.com/gaze-network/indexer-network/modules/runes/internal/entity"
"github.com/gaze-network/indexer-network/modules/runes/runes" "github.com/gaze-network/indexer-network/modules/runes/runes"
"github.com/gaze-network/uint128" "github.com/gaze-network/uint128"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -14,8 +14,10 @@ import (
) )
type getTokenInfoRequest struct { type getTokenInfoRequest struct {
Id string `params:"id"` Id string `params:"id"`
BlockHeight uint64 `query:"blockHeight"` BlockHeight uint64 `query:"blockHeight"`
AdditionalFieldsRaw string `query:"additionalFields"` // comma-separated list of additional fields
AdditionalFields []string
} }
func (r *getTokenInfoRequest) Validate() error { func (r *getTokenInfoRequest) Validate() error {
@@ -28,6 +30,13 @@ func (r *getTokenInfoRequest) Validate() error {
if !isRuneIdOrRuneName(r.Id) { if !isRuneIdOrRuneName(r.Id) {
errList = append(errList, errors.Errorf("id '%s' is not valid rune id or rune name", r.Id)) errList = append(errList, errors.Errorf("id '%s' is not valid rune id or rune name", r.Id))
} }
if r.AdditionalFieldsRaw == "" {
// temporarily set default value for backward compatibility
r.AdditionalFieldsRaw = "holdersCount" // TODO: remove this default value after all clients are updated
}
r.AdditionalFields = strings.Split(r.AdditionalFieldsRaw, ",")
return errs.WithPublicMessage(errors.Join(errList...), "validation error") return errs.WithPublicMessage(errors.Join(errList...), "validation error")
} }
@@ -52,7 +61,8 @@ type entry struct {
} }
type tokenInfoExtend struct { type tokenInfoExtend struct {
Entry entry `json:"entry"` HoldersCount *int64 `json:"holdersCount,omitempty"`
Entry entry `json:"entry"`
} }
type getTokenInfoResult struct { type getTokenInfoResult struct {
@@ -68,7 +78,7 @@ type getTokenInfoResult struct {
DeployedAtHeight uint64 `json:"deployedAtHeight"` DeployedAtHeight uint64 `json:"deployedAtHeight"`
CompletedAt *int64 `json:"completedAt"` // unix timestamp CompletedAt *int64 `json:"completedAt"` // unix timestamp
CompletedAtHeight *uint64 `json:"completedAtHeight"` CompletedAtHeight *uint64 `json:"completedAtHeight"`
HoldersCount int `json:"holdersCount"` HoldersCount int64 `json:"holdersCount"` // deprecated // TODO: remove later
Extend tokenInfoExtend `json:"extend"` Extend tokenInfoExtend `json:"extend"`
} }
@@ -103,7 +113,7 @@ func (h *HttpHandler) GetTokenInfo(ctx *fiber.Ctx) (err error) {
var ok bool var ok bool
runeId, ok = h.resolveRuneId(ctx.UserContext(), req.Id) runeId, ok = h.resolveRuneId(ctx.UserContext(), req.Id)
if !ok { if !ok {
return errs.NewPublicError("unable to resolve rune id from \"id\"") return errs.NewPublicError(fmt.Sprintf("unable to resolve rune id \"%s\" from \"id\"", req.Id))
} }
} }
@@ -112,71 +122,78 @@ func (h *HttpHandler) GetTokenInfo(ctx *fiber.Ctx) (err error) {
if errors.Is(err, errs.NotFound) { if errors.Is(err, errs.NotFound) {
return errs.NewPublicError("rune not found") return errs.NewPublicError("rune not found")
} }
return errors.Wrap(err, "error during GetTokenInfoByHeight") return errors.Wrap(err, "error during GetRuneEntryByRuneIdAndHeight")
} }
holdingBalances, err := h.usecase.GetBalancesByRuneId(ctx.UserContext(), runeId, blockHeight, -1, 0) // get all balances var holdersCountPtr *int64
if err != nil { if lo.Contains(req.AdditionalFields, "holdersCount") {
if errors.Is(err, errs.NotFound) { holdersCount, err := h.usecase.GetTotalHoldersByRuneId(ctx.UserContext(), runeId, blockHeight)
return errs.NewPublicError("rune not found") if err != nil {
if errors.Is(err, errs.NotFound) {
return errs.NewPublicError("rune not found")
}
return errors.Wrap(err, "error during GetBalancesByRuneId")
} }
return errors.Wrap(err, "error during GetBalancesByRuneId") holdersCountPtr = &holdersCount
} }
holdingBalances = lo.Filter(holdingBalances, func(b *entity.Balance, _ int) bool { result, err := createTokenInfoResult(runeEntry, holdersCountPtr)
return !b.Amount.IsZero()
})
// sort by amount descending
slices.SortFunc(holdingBalances, func(i, j *entity.Balance) int {
return j.Amount.Cmp(i.Amount)
})
totalSupply, err := runeEntry.Supply()
if err != nil { if err != nil {
return errors.Wrap(err, "cannot get total supply of rune") return errors.Wrap(err, "error during createTokenInfoResult")
} }
mintedAmount, err := runeEntry.MintedAmount()
if err != nil {
return errors.Wrap(err, "cannot get minted amount of rune")
}
circulatingSupply := mintedAmount.Sub(runeEntry.BurnedAmount)
terms := lo.FromPtr(runeEntry.Terms)
resp := getTokenInfoResponse{ resp := getTokenInfoResponse{
Result: &getTokenInfoResult{ Result: result,
Id: runeId,
Name: runeEntry.SpacedRune,
Symbol: string(runeEntry.Symbol),
TotalSupply: totalSupply,
CirculatingSupply: circulatingSupply,
MintedAmount: mintedAmount,
BurnedAmount: runeEntry.BurnedAmount,
Decimals: runeEntry.Divisibility,
DeployedAt: runeEntry.EtchedAt.Unix(),
DeployedAtHeight: runeEntry.EtchingBlock,
CompletedAt: lo.Ternary(runeEntry.CompletedAt.IsZero(), nil, lo.ToPtr(runeEntry.CompletedAt.Unix())),
CompletedAtHeight: runeEntry.CompletedAtHeight,
HoldersCount: len(holdingBalances),
Extend: tokenInfoExtend{
Entry: entry{
Divisibility: runeEntry.Divisibility,
Premine: runeEntry.Premine,
Rune: runeEntry.SpacedRune.Rune,
Spacers: runeEntry.SpacedRune.Spacers,
Symbol: string(runeEntry.Symbol),
Terms: entryTerms{
Amount: lo.FromPtr(terms.Amount),
Cap: lo.FromPtr(terms.Cap),
HeightStart: terms.HeightStart,
HeightEnd: terms.HeightEnd,
OffsetStart: terms.OffsetStart,
OffsetEnd: terms.OffsetEnd,
},
Turbo: runeEntry.Turbo,
EtchingTxHash: runeEntry.EtchingTxHash.String(),
},
},
},
} }
return errors.WithStack(ctx.JSON(resp)) return errors.WithStack(ctx.JSON(resp))
} }
func createTokenInfoResult(runeEntry *runes.RuneEntry, holdersCount *int64) (*getTokenInfoResult, error) {
totalSupply, err := runeEntry.Supply()
if err != nil {
return nil, errors.Wrap(err, "cannot get total supply of rune")
}
mintedAmount, err := runeEntry.MintedAmount()
if err != nil {
return nil, errors.Wrap(err, "cannot get minted amount of rune")
}
circulatingSupply := mintedAmount.Sub(runeEntry.BurnedAmount)
terms := lo.FromPtr(runeEntry.Terms)
return &getTokenInfoResult{
Id: runeEntry.RuneId,
Name: runeEntry.SpacedRune,
Symbol: string(runeEntry.Symbol),
TotalSupply: totalSupply,
CirculatingSupply: circulatingSupply,
MintedAmount: mintedAmount,
BurnedAmount: runeEntry.BurnedAmount,
Decimals: runeEntry.Divisibility,
DeployedAt: runeEntry.EtchedAt.Unix(),
DeployedAtHeight: runeEntry.EtchingBlock,
CompletedAt: lo.Ternary(runeEntry.CompletedAt.IsZero(), nil, lo.ToPtr(runeEntry.CompletedAt.Unix())),
CompletedAtHeight: runeEntry.CompletedAtHeight,
HoldersCount: lo.FromPtr(holdersCount),
Extend: tokenInfoExtend{
HoldersCount: holdersCount,
Entry: entry{
Divisibility: runeEntry.Divisibility,
Premine: runeEntry.Premine,
Rune: runeEntry.SpacedRune.Rune,
Spacers: runeEntry.SpacedRune.Spacers,
Symbol: string(runeEntry.Symbol),
Terms: entryTerms{
Amount: lo.FromPtr(terms.Amount),
Cap: lo.FromPtr(terms.Cap),
HeightStart: terms.HeightStart,
HeightEnd: terms.HeightEnd,
OffsetStart: terms.OffsetStart,
OffsetEnd: terms.OffsetEnd,
},
Turbo: runeEntry.Turbo,
EtchingTxHash: runeEntry.EtchingTxHash.String(),
},
},
}, nil
}

View File

@@ -0,0 +1,118 @@
package httphandler
import (
"fmt"
"net/url"
"github.com/cockroachdb/errors"
"github.com/gaze-network/indexer-network/common/errs"
"github.com/gaze-network/indexer-network/modules/runes/runes"
"github.com/gofiber/fiber/v2"
"github.com/samber/lo"
)
type getTokenInfoBatchRequest struct {
Ids []string `json:"ids"`
BlockHeight uint64 `json:"blockHeight"`
AdditionalFields []string `json:"additionalFields"`
}
const getTokenInfoBatchMaxQueries = 100
func (r *getTokenInfoBatchRequest) Validate() error {
var errList []error
if len(r.Ids) == 0 {
errList = append(errList, errors.New("ids cannot be empty"))
}
if len(r.Ids) > getTokenInfoBatchMaxQueries {
errList = append(errList, errors.Errorf("cannot query more than %d ids", getTokenInfoBatchMaxQueries))
}
for i := range r.Ids {
id, err := url.QueryUnescape(r.Ids[i])
if err != nil {
return errors.WithStack(err)
}
r.Ids[i] = id
if !isRuneIdOrRuneName(r.Ids[i]) {
errList = append(errList, errors.Errorf("ids[%d]: id '%s' is not valid rune id or rune name", i, r.Ids[i]))
}
}
return errs.WithPublicMessage(errors.Join(errList...), "validation error")
}
type getTokenInfoBatchResult struct {
List []*getTokenInfoResult `json:"list"`
}
type getTokenInfoBatchResponse = HttpResponse[getTokenInfoBatchResult]
func (h *HttpHandler) GetTokenInfoBatch(ctx *fiber.Ctx) (err error) {
var req getTokenInfoBatchRequest
if err := ctx.BodyParser(&req); err != nil {
return errors.WithStack(err)
}
if err := req.Validate(); err != nil {
return errors.WithStack(err)
}
blockHeight := req.BlockHeight
if blockHeight == 0 {
blockHeader, err := h.usecase.GetLatestBlock(ctx.UserContext())
if err != nil {
if errors.Is(err, errs.NotFound) {
return errs.NewPublicError("latest block not found")
}
return errors.Wrap(err, "error during GetLatestBlock")
}
blockHeight = uint64(blockHeader.Height)
}
runeIds := make([]runes.RuneId, 0)
for i, id := range req.Ids {
runeId, ok := h.resolveRuneId(ctx.UserContext(), id)
if !ok {
return errs.NewPublicError(fmt.Sprintf("unable to resolve rune id \"%s\" from \"ids[%d]\"", id, i))
}
runeIds = append(runeIds, runeId)
}
runeEntries, err := h.usecase.GetRuneEntryByRuneIdAndHeightBatch(ctx.UserContext(), runeIds, blockHeight)
if err != nil {
return errors.Wrap(err, "error during GetRuneEntryByRuneIdAndHeightBatch")
}
holdersCounts := make(map[runes.RuneId]int64)
if lo.Contains(req.AdditionalFields, "holdersCount") {
holdersCounts, err = h.usecase.GetTotalHoldersByRuneIds(ctx.UserContext(), runeIds, blockHeight)
if err != nil {
return errors.Wrap(err, "error during GetBalancesByRuneId")
}
}
results := make([]*getTokenInfoResult, 0, len(runeIds))
for _, runeId := range runeIds {
runeEntry, ok := runeEntries[runeId]
if !ok {
return errs.NewPublicError(fmt.Sprintf("rune not found: %s", runeId))
}
var holdersCount *int64
if lo.Contains(req.AdditionalFields, "holdersCount") {
holdersCount = lo.ToPtr(holdersCounts[runeId])
}
result, err := createTokenInfoResult(runeEntry, holdersCount)
if err != nil {
return errors.Wrap(err, "error during createTokenInfoResult")
}
results = append(results, result)
}
resp := getTokenInfoBatchResponse{
Result: &getTokenInfoBatchResult{
List: results,
},
}
return errors.WithStack(ctx.JSON(resp))
}

View File

@@ -32,22 +32,31 @@ func (s GetTokensScope) IsValid() bool {
type getTokensRequest struct { type getTokensRequest struct {
paginationRequest paginationRequest
Search string `query:"search"` Search string `query:"search"`
BlockHeight uint64 `query:"blockHeight"` BlockHeight uint64 `query:"blockHeight"`
Scope GetTokensScope `query:"scope"` Scope GetTokensScope `query:"scope"`
AdditionalFieldsRaw string `query:"additionalFields"` // comma-separated list of additional fields
AdditionalFields []string
} }
func (req getTokensRequest) Validate() error { func (r *getTokensRequest) Validate() error {
var errList []error var errList []error
if err := req.paginationRequest.Validate(); err != nil { if err := r.paginationRequest.Validate(); err != nil {
errList = append(errList, err) errList = append(errList, err)
} }
if req.Limit > getTokensMaxLimit { if r.Limit > getTokensMaxLimit {
errList = append(errList, errors.Errorf("limit must be less than or equal to 1000")) errList = append(errList, errors.Errorf("limit must be less than or equal to 1000"))
} }
if req.Scope != "" && !req.Scope.IsValid() { if r.Scope != "" && !r.Scope.IsValid() {
errList = append(errList, errors.Errorf("invalid scope: %s", req.Scope)) errList = append(errList, errors.Errorf("invalid scope: %s", r.Scope))
} }
if r.AdditionalFieldsRaw == "" {
// temporarily set default value for backward compatibility
r.AdditionalFieldsRaw = "holdersCount" // TODO: remove this default value after all clients are updated
}
r.AdditionalFields = strings.Split(r.AdditionalFieldsRaw, ",")
return errs.WithPublicMessage(errors.Join(errList...), "validation error") return errs.WithPublicMessage(errors.Join(errList...), "validation error")
} }
@@ -62,7 +71,7 @@ func (req *getTokensRequest) ParseDefault() error {
} }
type getTokensResult struct { type getTokensResult struct {
List []getTokenInfoResult `json:"list"` List []*getTokenInfoResult `json:"list"`
} }
type getTokensResponse = HttpResponse[getTokensResult] type getTokensResponse = HttpResponse[getTokensResult]
@@ -111,62 +120,31 @@ func (h *HttpHandler) GetTokens(ctx *fiber.Ctx) (err error) {
} }
runeIds := lo.Map(entries, func(item *runes.RuneEntry, _ int) runes.RuneId { return item.RuneId }) runeIds := lo.Map(entries, func(item *runes.RuneEntry, _ int) runes.RuneId { return item.RuneId })
totalHolders, err := h.usecase.GetTotalHoldersByRuneIds(ctx.UserContext(), runeIds, blockHeight) holdersCounts := make(map[runes.RuneId]int64)
if err != nil { if lo.Contains(req.AdditionalFields, "holdersCount") {
return errors.Wrap(err, "error during GetTotalHoldersByRuneIds") holdersCounts, err = h.usecase.GetTotalHoldersByRuneIds(ctx.UserContext(), runeIds, blockHeight)
if err != nil {
return errors.Wrap(err, "error during GetTotalHoldersByRuneIds")
}
} }
result := make([]getTokenInfoResult, 0, len(entries)) results := make([]*getTokenInfoResult, 0, len(entries))
for _, ent := range entries { for _, ent := range entries {
totalSupply, err := ent.Supply() var holdersCount *int64
if err != nil { if lo.Contains(req.AdditionalFields, "holdersCount") {
return errors.Wrap(err, "cannot get total supply of rune") holdersCount = lo.ToPtr(holdersCounts[ent.RuneId])
} }
mintedAmount, err := ent.MintedAmount() result, err := createTokenInfoResult(ent, holdersCount)
if err != nil { if err != nil {
return errors.Wrap(err, "cannot get minted amount of rune") return errors.Wrap(err, "error during createTokenInfoResult")
} }
circulatingSupply := mintedAmount.Sub(ent.BurnedAmount)
terms := lo.FromPtr(ent.Terms) results = append(results, result)
result = append(result, getTokenInfoResult{
Id: ent.RuneId,
Name: ent.SpacedRune,
Symbol: string(ent.Symbol),
TotalSupply: totalSupply,
CirculatingSupply: circulatingSupply,
MintedAmount: mintedAmount,
BurnedAmount: ent.BurnedAmount,
Decimals: ent.Divisibility,
DeployedAt: ent.EtchedAt.Unix(),
DeployedAtHeight: ent.EtchingBlock,
CompletedAt: lo.Ternary(ent.CompletedAt.IsZero(), nil, lo.ToPtr(ent.CompletedAt.Unix())),
CompletedAtHeight: ent.CompletedAtHeight,
HoldersCount: int(totalHolders[ent.RuneId]),
Extend: tokenInfoExtend{
Entry: entry{
Divisibility: ent.Divisibility,
Premine: ent.Premine,
Rune: ent.SpacedRune.Rune,
Spacers: ent.SpacedRune.Spacers,
Symbol: string(ent.Symbol),
Terms: entryTerms{
Amount: lo.FromPtr(terms.Amount),
Cap: lo.FromPtr(terms.Cap),
HeightStart: terms.HeightStart,
HeightEnd: terms.HeightEnd,
OffsetStart: terms.OffsetStart,
OffsetEnd: terms.OffsetEnd,
},
Turbo: ent.Turbo,
},
},
})
} }
return errors.WithStack(ctx.JSON(getTokensResponse{ return errors.WithStack(ctx.JSON(getTokensResponse{
Result: &getTokensResult{ Result: &getTokensResult{
List: result, List: results,
}, },
})) }))
} }

View File

@@ -152,7 +152,7 @@ func (h *HttpHandler) GetTransactions(ctx *fiber.Ctx) (err error) {
var ok bool var ok bool
runeId, ok = h.resolveRuneId(ctx.UserContext(), req.Id) runeId, ok = h.resolveRuneId(ctx.UserContext(), req.Id)
if !ok { if !ok {
return errs.NewPublicError("unable to resolve rune id from \"id\"") return errs.NewPublicError(fmt.Sprintf("unable to resolve rune id \"%s\" from \"id\"", req.Id))
} }
} }

View File

@@ -12,6 +12,7 @@ func (h *HttpHandler) Mount(router fiber.Router) error {
r.Get("/transactions", h.GetTransactions) r.Get("/transactions", h.GetTransactions)
r.Get("/transactions/hash/:hash", h.GetTransactionByHash) r.Get("/transactions/hash/:hash", h.GetTransactionByHash)
r.Get("/holders/:id", h.GetHolders) r.Get("/holders/:id", h.GetHolders)
r.Post("/info/batch", h.GetTokenInfoBatch)
r.Get("/info/:id", h.GetTokenInfo) r.Get("/info/:id", h.GetTokenInfo)
r.Get("/utxos/wallet/:wallet", h.GetUTXOs) r.Get("/utxos/wallet/:wallet", h.GetUTXOs)
r.Post("/utxos/output/batch", h.GetUTXOsOutputByLocationBatch) r.Post("/utxos/output/batch", h.GetUTXOsOutputByLocationBatch)

View File

@@ -33,3 +33,13 @@ func (u *Usecase) GetTotalHoldersByRuneIds(ctx context.Context, runeIds []runes.
} }
return holders, nil return holders, nil
} }
func (u *Usecase) GetTotalHoldersByRuneId(ctx context.Context, runeId runes.RuneId, blockHeight uint64) (int64, error) {
holders, err := u.runesDg.GetTotalHoldersByRuneIds(ctx, []runes.RuneId{runeId}, blockHeight)
if err != nil {
return 0, errors.Wrap(err, "failed to get total holders by rune ids")
}
// defaults to zero holders if not found
return holders[runeId], nil
}

View File

@@ -40,11 +40,11 @@ func (u *Usecase) GetRuneEntryByRuneIdAndHeight(ctx context.Context, runeId rune
} }
func (u *Usecase) GetRuneEntryByRuneIdAndHeightBatch(ctx context.Context, runeIds []runes.RuneId, blockHeight uint64) (map[runes.RuneId]*runes.RuneEntry, error) { func (u *Usecase) GetRuneEntryByRuneIdAndHeightBatch(ctx context.Context, runeIds []runes.RuneId, blockHeight uint64) (map[runes.RuneId]*runes.RuneEntry, error) {
runeEntry, err := u.runesDg.GetRuneEntryByRuneIdAndHeightBatch(ctx, runeIds, blockHeight) runeEntries, err := u.runesDg.GetRuneEntryByRuneIdAndHeightBatch(ctx, runeIds, blockHeight)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to get rune entries by rune ids and height") return nil, errors.Wrap(err, "failed to get rune entries by rune ids and height")
} }
return runeEntry, nil return runeEntries, nil
} }
func (u *Usecase) GetRuneEntries(ctx context.Context, search string, blockHeight uint64, limit, offset int32) ([]*runes.RuneEntry, error) { func (u *Usecase) GetRuneEntries(ctx context.Context, search string, blockHeight uint64, limit, offset int32) ([]*runes.RuneEntry, error) {