diff --git a/.golangci.yaml b/.golangci.yaml index 4864087..51710af 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -101,3 +101,6 @@ linters-settings: attr-only: true key-naming-case: snake args-on-sep-lines: true + gosec: + excludes: + - G115 diff --git a/modules/nodesale/repository/postgres/gen/blocks.sql.go b/modules/nodesale/repository/postgres/gen/blocks.sql.go index 970bb84..fe766c6 100644 --- a/modules/nodesale/repository/postgres/gen/blocks.sql.go +++ b/modules/nodesale/repository/postgres/gen/blocks.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: blocks.sql package gen diff --git a/modules/nodesale/repository/postgres/gen/db.go b/modules/nodesale/repository/postgres/gen/db.go index 3ccd3c9..7cd43a6 100644 --- a/modules/nodesale/repository/postgres/gen/db.go +++ b/modules/nodesale/repository/postgres/gen/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 package gen diff --git a/modules/nodesale/repository/postgres/gen/events.sql.go b/modules/nodesale/repository/postgres/gen/events.sql.go index 1c4086a..a2fdb07 100644 --- a/modules/nodesale/repository/postgres/gen/events.sql.go +++ b/modules/nodesale/repository/postgres/gen/events.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: events.sql package gen diff --git a/modules/nodesale/repository/postgres/gen/models.go b/modules/nodesale/repository/postgres/gen/models.go index 91b25e0..2c935a9 100644 --- a/modules/nodesale/repository/postgres/gen/models.go +++ b/modules/nodesale/repository/postgres/gen/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 package gen diff --git a/modules/nodesale/repository/postgres/gen/nodes.sql.go b/modules/nodesale/repository/postgres/gen/nodes.sql.go index 4a3db85..8eafe7f 100644 --- a/modules/nodesale/repository/postgres/gen/nodes.sql.go +++ b/modules/nodesale/repository/postgres/gen/nodes.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: nodes.sql package gen diff --git a/modules/nodesale/repository/postgres/gen/nodesales.sql.go b/modules/nodesale/repository/postgres/gen/nodesales.sql.go index a6dc565..609454b 100644 --- a/modules/nodesale/repository/postgres/gen/nodesales.sql.go +++ b/modules/nodesale/repository/postgres/gen/nodesales.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: nodesales.sql package gen diff --git a/modules/nodesale/repository/postgres/gen/test.sql.go b/modules/nodesale/repository/postgres/gen/test.sql.go index 248ca00..d38df68 100644 --- a/modules/nodesale/repository/postgres/gen/test.sql.go +++ b/modules/nodesale/repository/postgres/gen/test.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: test.sql package gen diff --git a/modules/runes/api/httphandler/get_balances_by_address.go b/modules/runes/api/httphandler/get_balances_by_address.go index 7881413..25f6ff9 100644 --- a/modules/runes/api/httphandler/get_balances_by_address.go +++ b/modules/runes/api/httphandler/get_balances_by_address.go @@ -11,11 +11,10 @@ import ( ) type getBalancesRequest struct { + paginationRequest Wallet string `params:"wallet"` Id string `query:"id"` BlockHeight uint64 `query:"blockHeight"` - Limit int32 `query:"limit"` - Offset int32 `query:"offset"` } const ( @@ -66,8 +65,8 @@ func (h *HttpHandler) GetBalances(ctx *fiber.Ctx) (err error) { if err := req.Validate(); err != nil { return errors.WithStack(err) } - if req.Limit == 0 { - req.Limit = getBalancesDefaultLimit + if err := req.ParseDefault(); err != nil { + return errors.WithStack(err) } pkScript, ok := resolvePkScript(h.network, req.Wallet) diff --git a/modules/runes/api/httphandler/get_balances_by_address_batch.go b/modules/runes/api/httphandler/get_balances_by_address_batch.go index 10fddd9..aa60188 100644 --- a/modules/runes/api/httphandler/get_balances_by_address_batch.go +++ b/modules/runes/api/httphandler/get_balances_by_address_batch.go @@ -89,7 +89,7 @@ func (h *HttpHandler) GetBalancesBatch(ctx *fiber.Ctx) (err error) { } if query.Limit == 0 { - query.Limit = getBalancesMaxLimit + query.Limit = getBalancesDefaultLimit } balances, err := h.usecase.GetBalancesByPkScript(ctx, pkScript, blockHeight, query.Limit, query.Offset) diff --git a/modules/runes/api/httphandler/get_holders.go b/modules/runes/api/httphandler/get_holders.go index 13845ff..134bb4a 100644 --- a/modules/runes/api/httphandler/get_holders.go +++ b/modules/runes/api/httphandler/get_holders.go @@ -15,15 +15,13 @@ import ( ) type getHoldersRequest struct { + paginationRequest Id string `params:"id"` BlockHeight uint64 `query:"blockHeight"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` } const ( - getHoldersMaxLimit = 1000 - getHoldersDefaultLimit = 100 + getHoldersMaxLimit = 1000 ) func (r getHoldersRequest) Validate() error { @@ -68,6 +66,9 @@ func (h *HttpHandler) GetHolders(ctx *fiber.Ctx) (err error) { if err := req.Validate(); err != nil { return errors.WithStack(err) } + if err := req.ParseDefault(); err != nil { + return errors.WithStack(err) + } blockHeight := req.BlockHeight if blockHeight == 0 { @@ -78,10 +79,6 @@ func (h *HttpHandler) GetHolders(ctx *fiber.Ctx) (err error) { blockHeight = uint64(blockHeader.Height) } - if req.Limit == 0 { - req.Limit = getHoldersDefaultLimit - } - var runeId runes.RuneId if req.Id != "" { var ok bool diff --git a/modules/runes/api/httphandler/get_token_info.go b/modules/runes/api/httphandler/get_token_info.go index 1862410..e73bdd3 100644 --- a/modules/runes/api/httphandler/get_token_info.go +++ b/modules/runes/api/httphandler/get_token_info.go @@ -57,9 +57,9 @@ type getTokenInfoResult struct { MintedAmount uint128.Uint128 `json:"mintedAmount"` BurnedAmount uint128.Uint128 `json:"burnedAmount"` Decimals uint8 `json:"decimals"` - DeployedAt uint64 `json:"deployedAt"` // unix timestamp + DeployedAt int64 `json:"deployedAt"` // unix timestamp DeployedAtHeight uint64 `json:"deployedAtHeight"` - CompletedAt *uint64 `json:"completedAt"` // unix timestamp + CompletedAt *int64 `json:"completedAt"` // unix timestamp CompletedAtHeight *uint64 `json:"completedAtHeight"` HoldersCount int `json:"holdersCount"` Extend tokenInfoExtend `json:"extend"` @@ -144,9 +144,9 @@ func (h *HttpHandler) GetTokenInfo(ctx *fiber.Ctx) (err error) { MintedAmount: mintedAmount, BurnedAmount: runeEntry.BurnedAmount, Decimals: runeEntry.Divisibility, - DeployedAt: uint64(runeEntry.EtchedAt.Unix()), + DeployedAt: runeEntry.EtchedAt.Unix(), DeployedAtHeight: runeEntry.EtchingBlock, - CompletedAt: lo.Ternary(runeEntry.CompletedAt.IsZero(), nil, lo.ToPtr(uint64(runeEntry.CompletedAt.Unix()))), + CompletedAt: lo.Ternary(runeEntry.CompletedAt.IsZero(), nil, lo.ToPtr(runeEntry.CompletedAt.Unix())), CompletedAtHeight: runeEntry.CompletedAtHeight, HoldersCount: len(holdingBalances), Extend: tokenInfoExtend{ diff --git a/modules/runes/api/httphandler/get_tokens.go b/modules/runes/api/httphandler/get_tokens.go new file mode 100644 index 0000000..8d82a54 --- /dev/null +++ b/modules/runes/api/httphandler/get_tokens.go @@ -0,0 +1,172 @@ +package httphandler + +import ( + "fmt" + "strings" + + "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" +) + +const ( + getTokensMaxLimit = 1000 +) + +type GetTokensScope string + +const ( + GetTokensScopeAll GetTokensScope = "all" + GetTokensScopeOngoing GetTokensScope = "ongoing" +) + +func (s GetTokensScope) IsValid() bool { + switch s { + case GetTokensScopeAll, GetTokensScopeOngoing: + return true + } + return false +} + +type getTokensRequest struct { + paginationRequest + Search string `query:"search"` + BlockHeight uint64 `query:"blockHeight"` + Scope GetTokensScope `query:"scope"` +} + +func (req getTokensRequest) Validate() error { + var errList []error + if err := req.paginationRequest.Validate(); err != nil { + errList = append(errList, err) + } + if req.Limit > getTokensMaxLimit { + errList = append(errList, errors.Errorf("limit must be less than or equal to 1000")) + } + if req.Scope != "" && !req.Scope.IsValid() { + errList = append(errList, errors.Errorf("invalid scope: %s", req.Scope)) + } + return errs.WithPublicMessage(errors.Join(errList...), "validation error") +} + +func (req *getTokensRequest) ParseDefault() error { + if err := req.paginationRequest.ParseDefault(); err != nil { + return errors.WithStack(err) + } + if req.Scope == "" { + req.Scope = GetTokensScopeAll + } + return nil +} + +type getTokensResult struct { + List []getTokenInfoResult `json:"list"` +} + +type getTokensResponse = HttpResponse[getTokensResult] + +func (h *HttpHandler) GetTokens(ctx *fiber.Ctx) (err error) { + var req getTokensRequest + if err := ctx.QueryParser(&req); err != nil { + return errors.WithStack(err) + } + if err := req.Validate(); err != nil { + return errors.WithStack(err) + } + if err := req.ParseDefault(); 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) + } + + // remove spacers + search := strings.Replace(strings.Replace(req.Search, "•", "", -1), ".", "", -1) + + var entries []*runes.RuneEntry + switch req.Scope { + case GetTokensScopeAll: + entries, err = h.usecase.GetRuneEntries(ctx.UserContext(), search, blockHeight, req.Limit, req.Offset) + if err != nil { + return errors.Wrap(err, "error during GetRuneEntryList") + } + case GetTokensScopeOngoing: + entries, err = h.usecase.GetOngoingRuneEntries(ctx.UserContext(), search, blockHeight, req.Limit, req.Offset) + if err != nil { + return errors.Wrap(err, "error during GetRuneEntryList") + } + default: + return errs.NewPublicError(fmt.Sprintf("invalid scope: %s", req.Scope)) + } + + runeIds := lo.Map(entries, func(item *runes.RuneEntry, _ int) runes.RuneId { return item.RuneId }) + totalHolders, err := h.usecase.GetTotalHoldersByRuneIds(ctx.UserContext(), runeIds, blockHeight) + if err != nil { + return errors.Wrap(err, "error during GetTotalHoldersByRuneIds") + } + + result := make([]getTokenInfoResult, 0, len(entries)) + for _, ent := range entries { + totalSupply, err := ent.Supply() + if err != nil { + return errors.Wrap(err, "cannot get total supply of rune") + } + mintedAmount, err := ent.MintedAmount() + if err != nil { + return errors.Wrap(err, "cannot get minted amount of rune") + } + circulatingSupply := mintedAmount.Sub(ent.BurnedAmount) + + terms := lo.FromPtr(ent.Terms) + 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{ + Result: &getTokensResult{ + List: result, + }, + })) +} diff --git a/modules/runes/api/httphandler/get_transactions.go b/modules/runes/api/httphandler/get_transactions.go index e0c10dd..1f9158f 100644 --- a/modules/runes/api/httphandler/get_transactions.go +++ b/modules/runes/api/httphandler/get_transactions.go @@ -16,17 +16,15 @@ import ( ) type getTransactionsRequest struct { + paginationRequest Wallet string `query:"wallet"` Id string `query:"id"` FromBlock int64 `query:"fromBlock"` ToBlock int64 `query:"toBlock"` - Limit int32 `query:"limit"` - Offset int32 `query:"offset"` } const ( - getTransactionsMaxLimit = 3000 - getTransactionsDefaultLimit = 100 + getTransactionsMaxLimit = 3000 ) func (r getTransactionsRequest) Validate() error { @@ -128,6 +126,9 @@ func (h *HttpHandler) GetTransactions(ctx *fiber.Ctx) (err error) { if err := req.Validate(); err != nil { return errors.WithStack(err) } + if err := req.ParseDefault(); err != nil { + return errors.WithStack(err) + } var pkScript []byte if req.Wallet != "" { @@ -146,9 +147,6 @@ func (h *HttpHandler) GetTransactions(ctx *fiber.Ctx) (err error) { return errs.NewPublicError("unable to resolve rune id from \"id\"") } } - if req.Limit == 0 { - req.Limit = getTransactionsDefaultLimit - } // default to latest block if req.ToBlock == 0 { diff --git a/modules/runes/api/httphandler/get_utxos_by_address.go b/modules/runes/api/httphandler/get_utxos_by_address.go index 45d9ce5..a302675 100644 --- a/modules/runes/api/httphandler/get_utxos_by_address.go +++ b/modules/runes/api/httphandler/get_utxos_by_address.go @@ -12,16 +12,14 @@ import ( ) type getUTXOsRequest struct { + paginationRequest Wallet string `params:"wallet"` Id string `query:"id"` BlockHeight uint64 `query:"blockHeight"` - Limit int32 `query:"limit"` - Offset int32 `query:"offset"` } const ( - getUTXOsMaxLimit = 3000 - getUTXOsDefaultLimit = 100 + getUTXOsMaxLimit = 3000 ) func (r getUTXOsRequest) Validate() error { @@ -78,16 +76,15 @@ func (h *HttpHandler) GetUTXOs(ctx *fiber.Ctx) (err error) { if err := req.Validate(); err != nil { return errors.WithStack(err) } + if err := req.ParseDefault(); err != nil { + return errors.WithStack(err) + } pkScript, ok := resolvePkScript(h.network, req.Wallet) if !ok { return errs.NewPublicError("unable to resolve pkscript from \"wallet\"") } - if req.Limit == 0 { - req.Limit = getUTXOsDefaultLimit - } - blockHeight := req.BlockHeight if blockHeight == 0 { blockHeader, err := h.usecase.GetLatestBlock(ctx.UserContext()) diff --git a/modules/runes/api/httphandler/httphandler.go b/modules/runes/api/httphandler/httphandler.go index 6e0e9d5..922b467 100644 --- a/modules/runes/api/httphandler/httphandler.go +++ b/modules/runes/api/httphandler/httphandler.go @@ -7,7 +7,9 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" + "github.com/cockroachdb/errors" "github.com/gaze-network/indexer-network/common" + "github.com/gaze-network/indexer-network/common/errs" "github.com/gaze-network/indexer-network/modules/runes/runes" "github.com/gaze-network/indexer-network/modules/runes/usecase" "github.com/gaze-network/indexer-network/pkg/logger" @@ -31,6 +33,53 @@ type HttpResponse[T any] struct { Result *T `json:"result,omitempty"` } +type paginationRequest struct { + Offset int32 `query:"offset"` + Limit int32 `query:"limit"` + + // OrderBy string `query:"orderBy"` // ASC or DESC + // SortBy string `query:"sortBy"` // column name +} + +func (req paginationRequest) Validate() error { + var errList []error + + // this just safeguard for limit, + // each path should have own validation. + if req.Limit > 10000 { + errList = append(errList, errors.Errorf("too large limit")) + } + if req.Limit < 0 { + errList = append(errList, errors.Errorf("limit must be greater than or equal to 0")) + } + if req.Offset < 0 { + errList = append(errList, errors.Errorf("offset must be greater than or equal to 0")) + } + + // TODO: + // if req.OrderBy != "" && req.OrderBy != "ASC" && req.OrderBy != "DESC" { + // errList = append(errList, errors.Errorf("invalid orderBy value, must be `ASC` or `DESC`")) + // } + + return errs.WithPublicMessage(errors.Join(errList...), "pagination validation error") +} + +func (req *paginationRequest) ParseDefault() error { + if req == nil { + return nil + } + + if req.Limit == 0 { + req.Limit = 100 + } + + // TODO: + // if req.OrderBy == "" { + // req.OrderBy = "ASC" + // } + return nil +} + func resolvePkScript(network common.Network, wallet string) ([]byte, bool) { if wallet == "" { return nil, false diff --git a/modules/runes/api/httphandler/routes.go b/modules/runes/api/httphandler/routes.go index 9c5c9d1..6479639 100644 --- a/modules/runes/api/httphandler/routes.go +++ b/modules/runes/api/httphandler/routes.go @@ -16,5 +16,6 @@ func (h *HttpHandler) Mount(router fiber.Router) error { r.Post("/utxos/output/batch", h.GetUTXOsOutputByLocationBatch) r.Get("/utxos/output/:txHash", h.GetUTXOsOutputByLocation) r.Get("/block", h.GetCurrentBlock) + r.Get("/tokens", h.GetTokens) return nil } diff --git a/modules/runes/database/postgresql/migrations/000001_initialize_tables.up.sql b/modules/runes/database/postgresql/migrations/000001_initialize_tables.up.sql index 7fb8d31..a9fbd20 100644 --- a/modules/runes/database/postgresql/migrations/000001_initialize_tables.up.sql +++ b/modules/runes/database/postgresql/migrations/000001_initialize_tables.up.sql @@ -1,5 +1,6 @@ BEGIN; +CREATE EXTENSION pg_trgm; -- Indexer Client Information CREATE TABLE IF NOT EXISTS "runes_indexer_stats" ( @@ -48,6 +49,7 @@ CREATE TABLE IF NOT EXISTS "runes_entries" ( "etched_at" TIMESTAMP NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS runes_entries_rune_idx ON "runes_entries" USING BTREE ("rune"); +CREATE UNIQUE INDEX IF NOT EXISTS runes_entries_rune_gin_idx ON "runes_entries" USING GIN ("rune" gin_trgm_ops); -- to speed up queries with LIKE operator CREATE UNIQUE INDEX IF NOT EXISTS runes_entries_number_idx ON "runes_entries" USING BTREE ("number"); CREATE TABLE IF NOT EXISTS "runes_entry_states" ( diff --git a/modules/runes/database/postgresql/queries/data.sql b/modules/runes/database/postgresql/queries/data.sql index 732e095..78059bb 100644 --- a/modules/runes/database/postgresql/queries/data.sql +++ b/modules/runes/database/postgresql/queries/data.sql @@ -13,6 +13,12 @@ SELECT * FROM balances WHERE amount > 0 ORDER BY amount DESC, pkscript LIMIT $3 -- name: GetBalanceByPkScriptAndRuneId :one SELECT * FROM runes_balances WHERE pkscript = $1 AND rune_id = $2 AND block_height <= $3 ORDER BY block_height DESC LIMIT 1; +-- name: GetTotalHoldersByRuneIds :many +WITH balances AS ( + SELECT DISTINCT ON (rune_id, pkscript) * FROM runes_balances WHERE rune_id = ANY(@rune_ids::TEXT[]) AND block_height <= @block_height ORDER BY rune_id, pkscript, block_height DESC +) +SELECT rune_id, COUNT(DISTINCT pkscript) FROM balances WHERE amount > 0 GROUP BY rune_id; + -- name: GetOutPointBalancesAtOutPoint :many SELECT * FROM runes_outpoint_balances WHERE tx_hash = $1 AND tx_idx = $2; @@ -57,6 +63,47 @@ SELECT * FROM runes_entries LEFT JOIN states ON runes_entries.rune_id = states.rune_id WHERE runes_entries.rune_id = ANY(@rune_ids::text[]) AND etching_block <= @height; +-- name: GetRuneEntries :many +WITH states AS ( + -- select latest state + SELECT DISTINCT ON (rune_id) * FROM runes_entry_states WHERE block_height <= @height ORDER BY rune_id, block_height DESC +) +SELECT * FROM runes_entries + LEFT JOIN states ON runes_entries.rune_id = states.rune_id + WHERE ( + @search = '' OR + runes_entries.rune ILIKE @search || '%' + ) + ORDER BY runes_entries.number + LIMIT @_limit OFFSET @_offset; + +-- name: GetOngoingRuneEntries :many +WITH states AS ( + -- select latest state + SELECT DISTINCT ON (rune_id) * FROM runes_entry_states WHERE block_height <= @height::integer ORDER BY rune_id, block_height DESC +) +SELECT * FROM runes_entries + LEFT JOIN states ON runes_entries.rune_id = states.rune_id + WHERE ( + runes_entries.terms = TRUE AND + states.mints < runes_entries.terms_cap AND + ( + runes_entries.terms_height_start IS NULL OR runes_entries.terms_height_start <= @height::integer + ) AND ( + runes_entries.terms_height_end IS NULL OR @height::integer <= runes_entries.terms_height_end + ) AND ( + runes_entries.terms_offset_start IS NULL OR runes_entries.terms_offset_start + runes_entries.etching_block <= @height::integer + ) AND ( + runes_entries.terms_offset_end IS NULL OR @height::integer <= runes_entries.terms_offset_start + runes_entries.etching_block + ) + + ) AND ( + @search::text = '' OR + runes_entries.rune ILIKE @search::text || '%' + ) + ORDER BY (states.mints / runes_entries.terms_cap::float) DESC + LIMIT @_limit OFFSET @_offset; + -- name: GetRuneIdFromRune :one SELECT rune_id FROM runes_entries WHERE rune = $1; diff --git a/modules/runes/datagateway/runes.go b/modules/runes/datagateway/runes.go index eda9179..b673f80 100644 --- a/modules/runes/datagateway/runes.go +++ b/modules/runes/datagateway/runes.go @@ -44,6 +44,10 @@ type RunesReaderDataGateway interface { GetRuneEntryByRuneIdAndHeight(ctx context.Context, runeId runes.RuneId, blockHeight uint64) (*runes.RuneEntry, error) // GetRuneEntryByRuneIdAndHeightBatch returns the RuneEntries for the given runeIds and block height. GetRuneEntryByRuneIdAndHeightBatch(ctx context.Context, runeIds []runes.RuneId, blockHeight uint64) (map[runes.RuneId]*runes.RuneEntry, error) + // GetRuneEntries returns a list of rune entries, sorted by etching order. If search is not empty, it will filter the results by rune name (prefix). + GetRuneEntries(ctx context.Context, search string, blockHeight uint64, limit int32, offset int32) ([]*runes.RuneEntry, error) + // GetOngoingRuneEntries returns a list of ongoing rune entries (can still mint), sorted by mint progress percent. If search is not empty, it will filter the results by rune name (prefix). + GetOngoingRuneEntries(ctx context.Context, search string, blockHeight uint64, limit int32, offset int32) ([]*runes.RuneEntry, error) // CountRuneEntries returns the number of existing rune entries. CountRuneEntries(ctx context.Context) (uint64, error) @@ -56,6 +60,8 @@ type RunesReaderDataGateway interface { GetBalancesByRuneId(ctx context.Context, runeId runes.RuneId, blockHeight uint64, limit int32, offset int32) ([]*entity.Balance, error) // GetBalancesByPkScriptAndRuneId returns the balance for the given pkScript and runeId at the given blockHeight. GetBalanceByPkScriptAndRuneId(ctx context.Context, pkScript []byte, runeId runes.RuneId, blockHeight uint64) (*entity.Balance, error) + // GetTotalHoldersByRuneIds returns the total holders of each the given runeIds. + GetTotalHoldersByRuneIds(ctx context.Context, runeIds []runes.RuneId, blockHeight uint64) (map[runes.RuneId]int64, error) } type RunesWriterDataGateway interface { diff --git a/modules/runes/repository/postgres/gen/batch.go b/modules/runes/repository/postgres/gen/batch.go index 177306b..7642018 100644 --- a/modules/runes/repository/postgres/gen/batch.go +++ b/modules/runes/repository/postgres/gen/batch.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: batch.go package gen diff --git a/modules/runes/repository/postgres/gen/data.sql.go b/modules/runes/repository/postgres/gen/data.sql.go index 600b3fd..bfbf9d6 100644 --- a/modules/runes/repository/postgres/gen/data.sql.go +++ b/modules/runes/repository/postgres/gen/data.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: data.sql package gen @@ -428,6 +428,118 @@ func (q *Queries) GetLatestIndexedBlock(ctx context.Context) (RunesIndexedBlock, return i, err } +const getOngoingRuneEntries = `-- name: GetOngoingRuneEntries :many +WITH states AS ( + -- select latest state + SELECT DISTINCT ON (rune_id) rune_id, block_height, mints, burned_amount, completed_at, completed_at_height FROM runes_entry_states WHERE block_height <= $1::integer ORDER BY rune_id, block_height DESC +) +SELECT runes_entries.rune_id, number, rune, spacers, premine, symbol, divisibility, terms, terms_amount, terms_cap, terms_height_start, terms_height_end, terms_offset_start, terms_offset_end, turbo, etching_block, etching_tx_hash, etched_at, states.rune_id, block_height, mints, burned_amount, completed_at, completed_at_height FROM runes_entries + LEFT JOIN states ON runes_entries.rune_id = states.rune_id + WHERE ( + runes_entries.terms = TRUE AND + states.mints < runes_entries.terms_cap AND + ( + runes_entries.terms_height_start IS NULL OR runes_entries.terms_height_start <= $1::integer + ) AND ( + runes_entries.terms_height_end IS NULL OR $1::integer <= runes_entries.terms_height_end + ) AND ( + runes_entries.terms_offset_start IS NULL OR runes_entries.terms_offset_start + runes_entries.etching_block <= $1::integer + ) AND ( + runes_entries.terms_offset_end IS NULL OR $1::integer <= runes_entries.terms_offset_start + runes_entries.etching_block + ) + + ) AND ( + $2::text = '' OR + runes_entries.rune ILIKE $2::text || '%' + ) + ORDER BY (states.mints / runes_entries.terms_cap::float) DESC + LIMIT $4 OFFSET $3 +` + +type GetOngoingRuneEntriesParams struct { + Height int32 + Search string + Offset int32 + Limit int32 +} + +type GetOngoingRuneEntriesRow struct { + RuneID string + Number int64 + Rune string + Spacers int32 + Premine pgtype.Numeric + Symbol int32 + Divisibility int16 + Terms bool + TermsAmount pgtype.Numeric + TermsCap pgtype.Numeric + TermsHeightStart pgtype.Int4 + TermsHeightEnd pgtype.Int4 + TermsOffsetStart pgtype.Int4 + TermsOffsetEnd pgtype.Int4 + Turbo bool + EtchingBlock int32 + EtchingTxHash string + EtchedAt pgtype.Timestamp + RuneID_2 pgtype.Text + BlockHeight pgtype.Int4 + Mints pgtype.Numeric + BurnedAmount pgtype.Numeric + CompletedAt pgtype.Timestamp + CompletedAtHeight pgtype.Int4 +} + +func (q *Queries) GetOngoingRuneEntries(ctx context.Context, arg GetOngoingRuneEntriesParams) ([]GetOngoingRuneEntriesRow, error) { + rows, err := q.db.Query(ctx, getOngoingRuneEntries, + arg.Height, + arg.Search, + arg.Offset, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetOngoingRuneEntriesRow + for rows.Next() { + var i GetOngoingRuneEntriesRow + if err := rows.Scan( + &i.RuneID, + &i.Number, + &i.Rune, + &i.Spacers, + &i.Premine, + &i.Symbol, + &i.Divisibility, + &i.Terms, + &i.TermsAmount, + &i.TermsCap, + &i.TermsHeightStart, + &i.TermsHeightEnd, + &i.TermsOffsetStart, + &i.TermsOffsetEnd, + &i.Turbo, + &i.EtchingBlock, + &i.EtchingTxHash, + &i.EtchedAt, + &i.RuneID_2, + &i.BlockHeight, + &i.Mints, + &i.BurnedAmount, + &i.CompletedAt, + &i.CompletedAtHeight, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getOutPointBalancesAtOutPoint = `-- name: GetOutPointBalancesAtOutPoint :many SELECT rune_id, pkscript, tx_hash, tx_idx, amount, block_height, spent_height FROM runes_outpoint_balances WHERE tx_hash = $1 AND tx_idx = $2 ` @@ -465,6 +577,105 @@ func (q *Queries) GetOutPointBalancesAtOutPoint(ctx context.Context, arg GetOutP return items, nil } +const getRuneEntries = `-- name: GetRuneEntries :many +WITH states AS ( + -- select latest state + SELECT DISTINCT ON (rune_id) rune_id, block_height, mints, burned_amount, completed_at, completed_at_height FROM runes_entry_states WHERE block_height <= $4 ORDER BY rune_id, block_height DESC +) +SELECT runes_entries.rune_id, number, rune, spacers, premine, symbol, divisibility, terms, terms_amount, terms_cap, terms_height_start, terms_height_end, terms_offset_start, terms_offset_end, turbo, etching_block, etching_tx_hash, etched_at, states.rune_id, block_height, mints, burned_amount, completed_at, completed_at_height FROM runes_entries + LEFT JOIN states ON runes_entries.rune_id = states.rune_id + WHERE ( + $1 = '' OR + runes_entries.rune ILIKE $1 || '%' + ) + ORDER BY runes_entries.number + LIMIT $3 OFFSET $2 +` + +type GetRuneEntriesParams struct { + Search interface{} + Offset int32 + Limit int32 + Height int32 +} + +type GetRuneEntriesRow struct { + RuneID string + Number int64 + Rune string + Spacers int32 + Premine pgtype.Numeric + Symbol int32 + Divisibility int16 + Terms bool + TermsAmount pgtype.Numeric + TermsCap pgtype.Numeric + TermsHeightStart pgtype.Int4 + TermsHeightEnd pgtype.Int4 + TermsOffsetStart pgtype.Int4 + TermsOffsetEnd pgtype.Int4 + Turbo bool + EtchingBlock int32 + EtchingTxHash string + EtchedAt pgtype.Timestamp + RuneID_2 pgtype.Text + BlockHeight pgtype.Int4 + Mints pgtype.Numeric + BurnedAmount pgtype.Numeric + CompletedAt pgtype.Timestamp + CompletedAtHeight pgtype.Int4 +} + +func (q *Queries) GetRuneEntries(ctx context.Context, arg GetRuneEntriesParams) ([]GetRuneEntriesRow, error) { + rows, err := q.db.Query(ctx, getRuneEntries, + arg.Search, + arg.Offset, + arg.Limit, + arg.Height, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetRuneEntriesRow + for rows.Next() { + var i GetRuneEntriesRow + if err := rows.Scan( + &i.RuneID, + &i.Number, + &i.Rune, + &i.Spacers, + &i.Premine, + &i.Symbol, + &i.Divisibility, + &i.Terms, + &i.TermsAmount, + &i.TermsCap, + &i.TermsHeightStart, + &i.TermsHeightEnd, + &i.TermsOffsetStart, + &i.TermsOffsetEnd, + &i.Turbo, + &i.EtchingBlock, + &i.EtchingTxHash, + &i.EtchedAt, + &i.RuneID_2, + &i.BlockHeight, + &i.Mints, + &i.BurnedAmount, + &i.CompletedAt, + &i.CompletedAtHeight, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getRuneEntriesByRuneIds = `-- name: GetRuneEntriesByRuneIds :many WITH states AS ( -- select latest state @@ -971,6 +1182,43 @@ func (q *Queries) GetRunesUTXOsByRuneIdAndPkScript(ctx context.Context, arg GetR return items, nil } +const getTotalHoldersByRuneIds = `-- name: GetTotalHoldersByRuneIds :many +WITH balances AS ( + SELECT DISTINCT ON (rune_id, pkscript) pkscript, block_height, rune_id, amount FROM runes_balances WHERE rune_id = ANY($1::TEXT[]) AND block_height <= $2 ORDER BY rune_id, pkscript, block_height DESC +) +SELECT rune_id, COUNT(DISTINCT pkscript) FROM balances WHERE amount > 0 GROUP BY rune_id +` + +type GetTotalHoldersByRuneIdsParams struct { + RuneIds []string + BlockHeight int32 +} + +type GetTotalHoldersByRuneIdsRow struct { + RuneID string + Count int64 +} + +func (q *Queries) GetTotalHoldersByRuneIds(ctx context.Context, arg GetTotalHoldersByRuneIdsParams) ([]GetTotalHoldersByRuneIdsRow, error) { + rows, err := q.db.Query(ctx, getTotalHoldersByRuneIds, arg.RuneIds, arg.BlockHeight) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTotalHoldersByRuneIdsRow + for rows.Next() { + var i GetTotalHoldersByRuneIdsRow + if err := rows.Scan(&i.RuneID, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const spendOutPointBalances = `-- name: SpendOutPointBalances :exec UPDATE runes_outpoint_balances SET spent_height = $1 WHERE tx_hash = $2 AND tx_idx = $3 ` diff --git a/modules/runes/repository/postgres/gen/db.go b/modules/runes/repository/postgres/gen/db.go index 150a59a..0e80698 100644 --- a/modules/runes/repository/postgres/gen/db.go +++ b/modules/runes/repository/postgres/gen/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 package gen diff --git a/modules/runes/repository/postgres/gen/info.sql.go b/modules/runes/repository/postgres/gen/info.sql.go index 815f21d..2ec375e 100644 --- a/modules/runes/repository/postgres/gen/info.sql.go +++ b/modules/runes/repository/postgres/gen/info.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: info.sql package gen diff --git a/modules/runes/repository/postgres/gen/models.go b/modules/runes/repository/postgres/gen/models.go index 2a85858..af16422 100644 --- a/modules/runes/repository/postgres/gen/models.go +++ b/modules/runes/repository/postgres/gen/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 package gen diff --git a/modules/runes/repository/postgres/mapper.go b/modules/runes/repository/postgres/mapper.go index eabbbb5..2b27b69 100644 --- a/modules/runes/repository/postgres/mapper.go +++ b/modules/runes/repository/postgres/mapper.go @@ -63,7 +63,7 @@ func mapIndexerStateTypeToParams(src entity.IndexerState) gen.SetIndexerStatePar } } -func mapRuneEntryModelToType(src gen.GetRuneEntriesByRuneIdsRow) (runes.RuneEntry, error) { +func mapRuneEntryModelToType(src gen.GetRuneEntriesRow) (runes.RuneEntry, error) { runeId, err := runes.NewRuneIdFromString(src.RuneID) if err != nil { return runes.RuneEntry{}, errors.Wrap(err, "failed to parse rune id") diff --git a/modules/runes/repository/postgres/runes.go b/modules/runes/repository/postgres/runes.go index 1b1d3d0..d9e40a7 100644 --- a/modules/runes/repository/postgres/runes.go +++ b/modules/runes/repository/postgres/runes.go @@ -262,7 +262,7 @@ func (r *Repository) GetRuneEntryByRuneIdBatch(ctx context.Context, runeIds []ru runeEntries := make(map[runes.RuneId]*runes.RuneEntry, len(rows)) var errs []error for i, runeEntryModel := range rows { - runeEntry, err := mapRuneEntryModelToType(runeEntryModel) + runeEntry, err := mapRuneEntryModelToType(gen.GetRuneEntriesRow(runeEntryModel)) if err != nil { errs = append(errs, errors.Wrapf(err, "failed to parse rune entry model index %d", i)) continue @@ -302,7 +302,7 @@ func (r *Repository) GetRuneEntryByRuneIdAndHeightBatch(ctx context.Context, run runeEntries := make(map[runes.RuneId]*runes.RuneEntry, len(rows)) var errs []error for i, runeEntryModel := range rows { - runeEntry, err := mapRuneEntryModelToType(gen.GetRuneEntriesByRuneIdsRow(runeEntryModel)) + runeEntry, err := mapRuneEntryModelToType(gen.GetRuneEntriesRow(runeEntryModel)) if err != nil { errs = append(errs, errors.Wrapf(err, "failed to parse rune entry model index %d", i)) continue @@ -316,6 +316,62 @@ func (r *Repository) GetRuneEntryByRuneIdAndHeightBatch(ctx context.Context, run return runeEntries, nil } +func (r *Repository) GetRuneEntries(ctx context.Context, search string, blockHeight uint64, limit int32, offset int32) ([]*runes.RuneEntry, error) { + rows, err := r.queries.GetRuneEntries(ctx, gen.GetRuneEntriesParams{ + Search: search, + Height: int32(blockHeight), + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, errors.Wrap(err, "error during query") + } + + runeEntries := make([]*runes.RuneEntry, 0, len(rows)) + var errs []error + for i, model := range rows { + runeEntry, err := mapRuneEntryModelToType(model) + if err != nil { + errs = append(errs, errors.Wrapf(err, "failed to parse rune entry model index %d", i)) + continue + } + runeEntries = append(runeEntries, &runeEntry) + } + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + + return runeEntries, nil +} + +func (r *Repository) GetOngoingRuneEntries(ctx context.Context, search string, blockHeight uint64, limit int32, offset int32) ([]*runes.RuneEntry, error) { + rows, err := r.queries.GetOngoingRuneEntries(ctx, gen.GetOngoingRuneEntriesParams{ + Search: search, + Height: int32(blockHeight), + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, errors.Wrap(err, "error during query") + } + + runeEntries := make([]*runes.RuneEntry, 0, len(rows)) + var errs []error + for i, model := range rows { + runeEntry, err := mapRuneEntryModelToType(gen.GetRuneEntriesRow(model)) + if err != nil { + errs = append(errs, errors.Wrapf(err, "failed to parse rune entry model index %d", i)) + continue + } + runeEntries = append(runeEntries, &runeEntry) + } + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + + return runeEntries, nil +} + func (r *Repository) CountRuneEntries(ctx context.Context) (uint64, error) { count, err := r.queries.CountRuneEntries(ctx) if err != nil { @@ -400,6 +456,25 @@ func (r *Repository) GetBalanceByPkScriptAndRuneId(ctx context.Context, pkScript return result, nil } +func (r *Repository) GetTotalHoldersByRuneIds(ctx context.Context, runeIds []runes.RuneId, blockHeight uint64) (map[runes.RuneId]int64, error) { + rows, err := r.queries.GetTotalHoldersByRuneIds(ctx, gen.GetTotalHoldersByRuneIdsParams{ + RuneIds: lo.Map(runeIds, func(runeId runes.RuneId, _ int) string { return runeId.String() }), + BlockHeight: int32(blockHeight), + }) + if err != nil { + return nil, errors.Wrap(err, "error during query") + } + holders := make(map[runes.RuneId]int64, len(rows)) + for _, row := range rows { + runeId, err := runes.NewRuneIdFromString(row.RuneID) + if err != nil { + return nil, errors.Wrap(err, "failed to parse RuneId") + } + holders[runeId] = row.Count + } + return holders, nil +} + func (r *Repository) CreateRuneTransaction(ctx context.Context, tx *entity.RuneTransaction) error { if tx == nil { return nil diff --git a/modules/runes/usecase/get_balances.go b/modules/runes/usecase/get_balances.go index 838f80e..bc706a6 100644 --- a/modules/runes/usecase/get_balances.go +++ b/modules/runes/usecase/get_balances.go @@ -25,3 +25,11 @@ func (u *Usecase) GetBalancesByRuneId(ctx context.Context, runeId runes.RuneId, } return balances, nil } + +func (u *Usecase) GetTotalHoldersByRuneIds(ctx context.Context, runeIds []runes.RuneId, blockHeight uint64) (map[runes.RuneId]int64, error) { + holders, err := u.runesDg.GetTotalHoldersByRuneIds(ctx, runeIds, blockHeight) + if err != nil { + return nil, errors.Wrap(err, "failed to get total holders by rune ids") + } + return holders, nil +} diff --git a/modules/runes/usecase/get_rune_entry.go b/modules/runes/usecase/get_rune_entry.go index 237c8f9..a3598c0 100644 --- a/modules/runes/usecase/get_rune_entry.go +++ b/modules/runes/usecase/get_rune_entry.go @@ -46,3 +46,19 @@ func (u *Usecase) GetRuneEntryByRuneIdAndHeightBatch(ctx context.Context, runeId } return runeEntry, nil } + +func (u *Usecase) GetRuneEntries(ctx context.Context, search string, blockHeight uint64, limit, offset int32) ([]*runes.RuneEntry, error) { + entries, err := u.runesDg.GetRuneEntries(ctx, search, blockHeight, limit, offset) + if err != nil { + return nil, errors.Wrap(err, "failed to listing rune entries") + } + return entries, nil +} + +func (u *Usecase) GetOngoingRuneEntries(ctx context.Context, search string, blockHeight uint64, limit, offset int32) ([]*runes.RuneEntry, error) { + entries, err := u.runesDg.GetOngoingRuneEntries(ctx, search, blockHeight, limit, offset) + if err != nil { + return nil, errors.Wrap(err, "failed to listing rune entries") + } + return entries, nil +}