diff --git a/.vscode/settings.json b/.vscode/settings.json index 164d963a..a5499832 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,7 +39,7 @@ "ui.completion.usePlaceholders": false, "ui.diagnostic.analyses": { // https://github.com/golang/tools/blob/master/gopls/doc/analyzers.md - // "fieldalignment": false, + "fieldalignment": false, "nilness": true, "shadow": false, "unusedparams": true, diff --git a/modules/runes/api/httphandler/get_balances_by_address.go b/modules/runes/api/httphandler/get_balances_by_address.go index 515903d6..78814137 100644 --- a/modules/runes/api/httphandler/get_balances_by_address.go +++ b/modules/runes/api/httphandler/get_balances_by_address.go @@ -1,23 +1,29 @@ package httphandler import ( - "slices" - "github.com/cockroachdb/errors" "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/uint128" "github.com/gofiber/fiber/v2" "github.com/samber/lo" ) -type getBalancesByAddressRequest struct { +type getBalancesRequest struct { Wallet string `params:"wallet"` Id string `query:"id"` BlockHeight uint64 `query:"blockHeight"` + Limit int32 `query:"limit"` + Offset int32 `query:"offset"` } -func (r getBalancesByAddressRequest) Validate() error { +const ( + getBalancesMaxLimit = 5000 + getBalancesDefaultLimit = 100 +) + +func (r getBalancesRequest) Validate() error { var errList []error if r.Wallet == "" { errList = append(errList, errors.New("'wallet' is required")) @@ -25,6 +31,12 @@ func (r getBalancesByAddressRequest) Validate() error { if r.Id != "" && !isRuneIdOrRuneName(r.Id) { errList = append(errList, errors.New("'id' is not valid rune id or rune name")) } + if r.Limit < 0 { + errList = append(errList, errors.New("'limit' must be non-negative")) + } + if r.Limit > getBalancesMaxLimit { + errList = append(errList, errors.Errorf("'limit' cannot exceed %d", getBalancesMaxLimit)) + } return errs.WithPublicMessage(errors.Join(errList...), "validation error") } @@ -36,15 +48,15 @@ type balance struct { Decimals uint8 `json:"decimals"` } -type getBalancesByAddressResult struct { +type getBalancesResult struct { List []balance `json:"list"` BlockHeight uint64 `json:"blockHeight"` } -type getBalancesByAddressResponse = HttpResponse[getBalancesByAddressResult] +type getBalancesResponse = HttpResponse[getBalancesResult] -func (h *HttpHandler) GetBalancesByAddress(ctx *fiber.Ctx) (err error) { - var req getBalancesByAddressRequest +func (h *HttpHandler) GetBalances(ctx *fiber.Ctx) (err error) { + var req getBalancesRequest if err := ctx.ParamsParser(&req); err != nil { return errors.WithStack(err) } @@ -54,6 +66,9 @@ func (h *HttpHandler) GetBalancesByAddress(ctx *fiber.Ctx) (err error) { if err := req.Validate(); err != nil { return errors.WithStack(err) } + if req.Limit == 0 { + req.Limit = getBalancesDefaultLimit + } pkScript, ok := resolvePkScript(h.network, req.Wallet) if !ok { @@ -64,49 +79,52 @@ func (h *HttpHandler) GetBalancesByAddress(ctx *fiber.Ctx) (err error) { 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) } - balances, err := h.usecase.GetBalancesByPkScript(ctx.UserContext(), pkScript, blockHeight) + balances, err := h.usecase.GetBalancesByPkScript(ctx.UserContext(), pkScript, blockHeight, req.Limit, req.Offset) if err != nil { + if errors.Is(err, errs.NotFound) { + return errs.NewPublicError("balances not found") + } return errors.Wrap(err, "error during GetBalancesByPkScript") } runeId, ok := h.resolveRuneId(ctx.UserContext(), req.Id) if ok { // filter out balances that don't match the requested rune id - for key := range balances { - if key != runeId { - delete(balances, key) - } - } + balances = lo.Filter(balances, func(b *entity.Balance, _ int) bool { + return b.RuneId == runeId + }) } - balanceRuneIds := lo.Keys(balances) + balanceRuneIds := lo.Map(balances, func(b *entity.Balance, _ int) runes.RuneId { + return b.RuneId + }) runeEntries, err := h.usecase.GetRuneEntryByRuneIdBatch(ctx.UserContext(), balanceRuneIds) if err != nil { return errors.Wrap(err, "error during GetRuneEntryByRuneIdBatch") } balanceList := make([]balance, 0, len(balances)) - for id, b := range balances { - runeEntry := runeEntries[id] + for _, b := range balances { + runeEntry := runeEntries[b.RuneId] balanceList = append(balanceList, balance{ Amount: b.Amount, - Id: id, + Id: b.RuneId, Name: runeEntry.SpacedRune, Symbol: string(runeEntry.Symbol), Decimals: runeEntry.Divisibility, }) } - slices.SortFunc(balanceList, func(i, j balance) int { - return j.Amount.Cmp(i.Amount) - }) - resp := getBalancesByAddressResponse{ - Result: &getBalancesByAddressResult{ + resp := getBalancesResponse{ + Result: &getBalancesResult{ BlockHeight: blockHeight, List: balanceList, }, 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 77ca29bd..10fddd91 100644 --- a/modules/runes/api/httphandler/get_balances_by_address_batch.go +++ b/modules/runes/api/httphandler/get_balances_by_address_batch.go @@ -3,10 +3,11 @@ package httphandler import ( "context" "fmt" - "slices" "github.com/cockroachdb/errors" "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/gofiber/fiber/v2" "github.com/samber/lo" "golang.org/x/sync/errgroup" @@ -16,33 +17,49 @@ type getBalanceQuery struct { Wallet string `json:"wallet"` Id string `json:"id"` BlockHeight uint64 `json:"blockHeight"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` } -type getBalancesByAddressBatchRequest struct { +type getBalancesBatchRequest struct { Queries []getBalanceQuery `json:"queries"` } -func (r getBalancesByAddressBatchRequest) Validate() error { +const getBalancesBatchMaxQueries = 100 + +func (r getBalancesBatchRequest) Validate() error { var errList []error - for _, query := range r.Queries { + if len(r.Queries) == 0 { + errList = append(errList, errors.New("at least one query is required")) + } + if len(r.Queries) > getBalancesBatchMaxQueries { + errList = append(errList, errors.Errorf("cannot exceed %d queries", getBalancesBatchMaxQueries)) + } + for i, query := range r.Queries { if query.Wallet == "" { - errList = append(errList, errors.Errorf("queries[%d]: 'wallet' is required")) + errList = append(errList, errors.Errorf("queries[%d]: 'wallet' is required", i)) } if query.Id != "" && !isRuneIdOrRuneName(query.Id) { - errList = append(errList, errors.Errorf("queries[%d]: 'id' is not valid rune id or rune name")) + errList = append(errList, errors.Errorf("queries[%d]: 'id' is not valid rune id or rune name", i)) + } + if query.Limit < 0 { + errList = append(errList, errors.Errorf("queries[%d]: 'limit' must be non-negative", i)) + } + if query.Limit > getBalancesMaxLimit { + errList = append(errList, errors.Errorf("queries[%d]: 'limit' cannot exceed %d", i, getBalancesMaxLimit)) } } return errs.WithPublicMessage(errors.Join(errList...), "validation error") } -type getBalancesByAddressBatchResult struct { - List []*getBalancesByAddressResult `json:"list"` +type getBalancesBatchResult struct { + List []*getBalancesResult `json:"list"` } -type getBalancesByAddressBatchResponse = HttpResponse[getBalancesByAddressBatchResult] +type getBalancesBatchResponse = HttpResponse[getBalancesBatchResult] -func (h *HttpHandler) GetBalancesByAddressBatch(ctx *fiber.Ctx) (err error) { - var req getBalancesByAddressBatchRequest +func (h *HttpHandler) GetBalancesBatch(ctx *fiber.Ctx) (err error) { + var req getBalancesBatchRequest if err := ctx.BodyParser(&req); err != nil { return errors.WithStack(err) } @@ -53,11 +70,14 @@ func (h *HttpHandler) GetBalancesByAddressBatch(ctx *fiber.Ctx) (err error) { var latestBlockHeight uint64 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") } latestBlockHeight = uint64(blockHeader.Height) - processQuery := func(ctx context.Context, query getBalanceQuery, queryIndex int) (*getBalancesByAddressResult, error) { + processQuery := func(ctx context.Context, query getBalanceQuery, queryIndex int) (*getBalancesResult, error) { pkScript, ok := resolvePkScript(h.network, query.Wallet) if !ok { return nil, errs.NewPublicError(fmt.Sprintf("unable to resolve pkscript from \"queries[%d].wallet\"", queryIndex)) @@ -68,50 +88,57 @@ func (h *HttpHandler) GetBalancesByAddressBatch(ctx *fiber.Ctx) (err error) { blockHeight = latestBlockHeight } - balances, err := h.usecase.GetBalancesByPkScript(ctx, pkScript, blockHeight) + if query.Limit == 0 { + query.Limit = getBalancesMaxLimit + } + + balances, err := h.usecase.GetBalancesByPkScript(ctx, pkScript, blockHeight, query.Limit, query.Offset) if err != nil { + if errors.Is(err, errs.NotFound) { + return nil, errs.NewPublicError("balances not found") + } return nil, errors.Wrap(err, "error during GetBalancesByPkScript") } runeId, ok := h.resolveRuneId(ctx, query.Id) if ok { // filter out balances that don't match the requested rune id - for key := range balances { - if key != runeId { - delete(balances, key) - } - } + balances = lo.Filter(balances, func(b *entity.Balance, _ int) bool { + return b.RuneId == runeId + }) } - balanceRuneIds := lo.Keys(balances) + balanceRuneIds := lo.Map(balances, func(b *entity.Balance, _ int) runes.RuneId { + return b.RuneId + }) runeEntries, err := h.usecase.GetRuneEntryByRuneIdBatch(ctx, balanceRuneIds) if err != nil { + if errors.Is(err, errs.NotFound) { + return nil, errs.NewPublicError("rune not found") + } return nil, errors.Wrap(err, "error during GetRuneEntryByRuneIdBatch") } balanceList := make([]balance, 0, len(balances)) - for id, b := range balances { - runeEntry := runeEntries[id] + for _, b := range balances { + runeEntry := runeEntries[b.RuneId] balanceList = append(balanceList, balance{ Amount: b.Amount, - Id: id, + Id: b.RuneId, Name: runeEntry.SpacedRune, Symbol: string(runeEntry.Symbol), Decimals: runeEntry.Divisibility, }) } - slices.SortFunc(balanceList, func(i, j balance) int { - return j.Amount.Cmp(i.Amount) - }) - result := getBalancesByAddressResult{ + result := getBalancesResult{ BlockHeight: blockHeight, List: balanceList, } return &result, nil } - results := make([]*getBalancesByAddressResult, len(req.Queries)) + results := make([]*getBalancesResult, len(req.Queries)) eg, ectx := errgroup.WithContext(ctx.UserContext()) for i, query := range req.Queries { i := i @@ -129,8 +156,8 @@ func (h *HttpHandler) GetBalancesByAddressBatch(ctx *fiber.Ctx) (err error) { return errors.WithStack(err) } - resp := getBalancesByAddressBatchResponse{ - Result: &getBalancesByAddressBatchResult{ + resp := getBalancesBatchResponse{ + Result: &getBalancesBatchResult{ List: results, }, } diff --git a/modules/runes/api/httphandler/get_holders.go b/modules/runes/api/httphandler/get_holders.go index 66b54573..95a48340 100644 --- a/modules/runes/api/httphandler/get_holders.go +++ b/modules/runes/api/httphandler/get_holders.go @@ -1,10 +1,13 @@ package httphandler import ( + "bytes" "encoding/hex" + "slices" "github.com/cockroachdb/errors" "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/uint128" "github.com/gofiber/fiber/v2" @@ -14,13 +17,26 @@ import ( type getHoldersRequest struct { Id string `params:"id"` BlockHeight uint64 `query:"blockHeight"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` } +const ( + getHoldersMaxLimit = 1000 + getHoldersDefaultLimit = 100 +) + func (r getHoldersRequest) Validate() error { var errList []error if !isRuneIdOrRuneName(r.Id) { errList = append(errList, errors.New("'id' is not valid rune id or rune name")) } + if r.Limit < 0 { + errList = append(errList, errors.New("'limit' must be non-negative")) + } + if r.Limit > getHoldersMaxLimit { + errList = append(errList, errors.Errorf("'limit' cannot exceed %d", getHoldersMaxLimit)) + } return errs.WithPublicMessage(errors.Join(errList...), "validation error") } @@ -61,6 +77,10 @@ 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 @@ -72,10 +92,16 @@ func (h *HttpHandler) GetHolders(ctx *fiber.Ctx) (err error) { runeEntry, err := h.usecase.GetRuneEntryByRuneIdAndHeight(ctx.UserContext(), runeId, blockHeight) if err != nil { - return errors.Wrap(err, "error during GetHoldersByHeight") + if errors.Is(err, errs.NotFound) { + return errs.NewPublicError("rune not found") + } + return errors.Wrap(err, "error during GetRuneEntryByRuneIdAndHeight") } - holdingBalances, err := h.usecase.GetBalancesByRuneId(ctx.UserContext(), runeId, blockHeight) + holdingBalances, err := h.usecase.GetBalancesByRuneId(ctx.UserContext(), runeId, blockHeight, req.Limit, req.Offset) if err != nil { + if errors.Is(err, errs.NotFound) { + return errs.NewPublicError("balances not found") + } return errors.Wrap(err, "error during GetBalancesByRuneId") } @@ -101,6 +127,14 @@ func (h *HttpHandler) GetHolders(ctx *fiber.Ctx) (err error) { }) } + // sort by amount descending, then pk script ascending + slices.SortFunc(holdingBalances, func(b1, b2 *entity.Balance) int { + if b1.Amount.Cmp(b2.Amount) == 0 { + return bytes.Compare(b1.PkScript, b2.PkScript) + } + return b2.Amount.Cmp(b1.Amount) + }) + resp := getHoldersResponse{ Result: &getHoldersResult{ BlockHeight: blockHeight, diff --git a/modules/runes/api/httphandler/get_token_info.go b/modules/runes/api/httphandler/get_token_info.go index d5b762ef..1862410a 100644 --- a/modules/runes/api/httphandler/get_token_info.go +++ b/modules/runes/api/httphandler/get_token_info.go @@ -83,6 +83,9 @@ func (h *HttpHandler) GetTokenInfo(ctx *fiber.Ctx) (err error) { 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) @@ -99,10 +102,16 @@ func (h *HttpHandler) GetTokenInfo(ctx *fiber.Ctx) (err error) { runeEntry, err := h.usecase.GetRuneEntryByRuneIdAndHeight(ctx.UserContext(), runeId, blockHeight) if err != nil { + if errors.Is(err, errs.NotFound) { + return errs.NewPublicError("rune not found") + } return errors.Wrap(err, "error during GetTokenInfoByHeight") } - holdingBalances, err := h.usecase.GetBalancesByRuneId(ctx.UserContext(), runeId, blockHeight) + holdingBalances, err := h.usecase.GetBalancesByRuneId(ctx.UserContext(), runeId, blockHeight, -1, 0) // get all balances if err != nil { + if errors.Is(err, errs.NotFound) { + return errs.NewPublicError("rune not found") + } return errors.Wrap(err, "error during GetBalancesByRuneId") } diff --git a/modules/runes/api/httphandler/get_transactions.go b/modules/runes/api/httphandler/get_transactions.go index 78f4dd57..e0c10dda 100644 --- a/modules/runes/api/httphandler/get_transactions.go +++ b/modules/runes/api/httphandler/get_transactions.go @@ -1,6 +1,7 @@ package httphandler import ( + "cmp" "encoding/hex" "fmt" "slices" @@ -15,13 +16,19 @@ import ( ) type getTransactionsRequest struct { - Wallet string `query:"wallet"` - Id string `query:"id"` - - FromBlock int64 `query:"fromBlock"` - ToBlock int64 `query:"toBlock"` + 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 +) + func (r getTransactionsRequest) Validate() error { var errList []error if r.Id != "" && !isRuneIdOrRuneName(r.Id) { @@ -33,6 +40,12 @@ func (r getTransactionsRequest) Validate() error { if r.ToBlock < -1 { errList = append(errList, errors.Errorf("invalid toBlock range")) } + if r.Limit < 0 { + errList = append(errList, errors.New("'limit' must be non-negative")) + } + if r.Limit > getTransactionsMaxLimit { + errList = append(errList, errors.Errorf("'limit' cannot exceed %d", getTransactionsMaxLimit)) + } return errs.WithPublicMessage(errors.Join(errList...), "validation error") } @@ -133,6 +146,9 @@ 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 { @@ -143,6 +159,9 @@ func (h *HttpHandler) GetTransactions(ctx *fiber.Ctx) (err error) { if req.FromBlock == -1 || req.ToBlock == -1 { 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") } if req.FromBlock == -1 { @@ -158,8 +177,11 @@ func (h *HttpHandler) GetTransactions(ctx *fiber.Ctx) (err error) { return errs.NewPublicError(fmt.Sprintf("fromBlock must be less than or equal to toBlock, got fromBlock=%d, toBlock=%d", req.FromBlock, req.ToBlock)) } - txs, err := h.usecase.GetRuneTransactions(ctx.UserContext(), pkScript, runeId, uint64(req.FromBlock), uint64(req.ToBlock)) + txs, err := h.usecase.GetRuneTransactions(ctx.UserContext(), pkScript, runeId, uint64(req.FromBlock), uint64(req.ToBlock), req.Limit, req.Offset) if err != nil { + if errors.Is(err, errs.NotFound) { + return errs.NewPublicError("transactions not found") + } return errors.Wrap(err, "error during GetRuneTransactions") } @@ -181,6 +203,9 @@ func (h *HttpHandler) GetTransactions(ctx *fiber.Ctx) (err error) { allRuneIds = lo.Uniq(allRuneIds) runeEntries, err := h.usecase.GetRuneEntryByRuneIdBatch(ctx.UserContext(), allRuneIds) if err != nil { + if errors.Is(err, errs.NotFound) { + return errs.NewPublicError("rune entries not found") + } return errors.Wrap(err, "error during GetRuneEntryByRuneIdBatch") } @@ -279,12 +304,12 @@ func (h *HttpHandler) GetTransactions(ctx *fiber.Ctx) (err error) { } txList = append(txList, respTx) } - // sort by block height ASC, then index ASC + // sort by block height DESC, then index DESC slices.SortFunc(txList, func(t1, t2 transaction) int { if t1.BlockHeight != t2.BlockHeight { - return int(t1.BlockHeight - t2.BlockHeight) + return cmp.Compare(t2.BlockHeight, t1.BlockHeight) } - return int(t1.Index - t2.Index) + return cmp.Compare(t2.Index, t1.Index) }) resp := getTransactionsResponse{ diff --git a/modules/runes/api/httphandler/get_utxos_by_address.go b/modules/runes/api/httphandler/get_utxos_by_address.go index e2acf718..9f84a9ed 100644 --- a/modules/runes/api/httphandler/get_utxos_by_address.go +++ b/modules/runes/api/httphandler/get_utxos_by_address.go @@ -2,7 +2,6 @@ package httphandler import ( "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/wire" "github.com/cockroachdb/errors" "github.com/gaze-network/indexer-network/common/errs" "github.com/gaze-network/indexer-network/modules/runes/internal/entity" @@ -12,13 +11,20 @@ import ( "github.com/samber/lo" ) -type getUTXOsByAddressRequest struct { +type getUTXOsRequest struct { Wallet string `params:"wallet"` Id string `query:"id"` BlockHeight uint64 `query:"blockHeight"` + Limit int32 `query:"limit"` + Offset int32 `query:"offset"` } -func (r getUTXOsByAddressRequest) Validate() error { +const ( + getUTXOsMaxLimit = 3000 + getUTXOsDefaultLimit = 100 +) + +func (r getUTXOsRequest) Validate() error { var errList []error if r.Wallet == "" { errList = append(errList, errors.New("'wallet' is required")) @@ -26,6 +32,12 @@ func (r getUTXOsByAddressRequest) Validate() error { if r.Id != "" && !isRuneIdOrRuneName(r.Id) { errList = append(errList, errors.New("'id' is not valid rune id or rune name")) } + if r.Limit < 0 { + errList = append(errList, errors.New("'limit' must be non-negative")) + } + if r.Limit > getUTXOsMaxLimit { + errList = append(errList, errors.Errorf("'limit' cannot exceed %d", getUTXOsMaxLimit)) + } return errs.WithPublicMessage(errors.Join(errList...), "validation error") } @@ -41,21 +53,21 @@ type utxoExtend struct { Runes []runeBalance `json:"runes"` } -type utxo struct { +type utxoItem struct { TxHash chainhash.Hash `json:"txHash"` OutputIndex uint32 `json:"outputIndex"` Extend utxoExtend `json:"extend"` } -type getUTXOsByAddressResult struct { - List []utxo `json:"list"` - BlockHeight uint64 `json:"blockHeight"` +type getUTXOsResult struct { + List []utxoItem `json:"list"` + BlockHeight uint64 `json:"blockHeight"` } -type getUTXOsByAddressResponse = HttpResponse[getUTXOsByAddressResult] +type getUTXOsResponse = HttpResponse[getUTXOsResult] -func (h *HttpHandler) GetUTXOsByAddress(ctx *fiber.Ctx) (err error) { - var req getUTXOsByAddressRequest +func (h *HttpHandler) GetUTXOs(ctx *fiber.Ctx) (err error) { + var req getUTXOsRequest if err := ctx.ParamsParser(&req); err != nil { return errors.WithStack(err) } @@ -71,36 +83,60 @@ func (h *HttpHandler) GetUTXOsByAddress(ctx *fiber.Ctx) (err error) { 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()) 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) } - outPointBalances, err := h.usecase.GetUnspentOutPointBalancesByPkScript(ctx.UserContext(), pkScript, blockHeight) - if err != nil { - return errors.Wrap(err, "error during GetBalancesByPkScript") + var utxos []*entity.RunesUTXO + if runeId, ok := h.resolveRuneId(ctx.UserContext(), req.Id); ok { + utxos, err = h.usecase.GetRunesUTXOsByRuneIdAndPkScript(ctx.UserContext(), runeId, pkScript, blockHeight, req.Limit, req.Offset) + if err != nil { + if errors.Is(err, errs.NotFound) { + return errs.NewPublicError("utxos not found") + } + return errors.Wrap(err, "error during GetBalancesByPkScript") + } + } else { + utxos, err = h.usecase.GetRunesUTXOsByPkScript(ctx.UserContext(), pkScript, blockHeight, req.Limit, req.Offset) + if err != nil { + if errors.Is(err, errs.NotFound) { + return errs.NewPublicError("utxos not found") + } + return errors.Wrap(err, "error during GetBalancesByPkScript") + } } - outPointBalanceRuneIds := lo.Map(outPointBalances, func(outPointBalance *entity.OutPointBalance, _ int) runes.RuneId { - return outPointBalance.RuneId - }) - runeEntries, err := h.usecase.GetRuneEntryByRuneIdBatch(ctx.UserContext(), outPointBalanceRuneIds) + runeIds := make(map[runes.RuneId]struct{}, 0) + for _, utxo := range utxos { + for _, balance := range utxo.RuneBalances { + runeIds[balance.RuneId] = struct{}{} + } + } + runeIdsList := lo.Keys(runeIds) + runeEntries, err := h.usecase.GetRuneEntryByRuneIdBatch(ctx.UserContext(), runeIdsList) if err != nil { + if errors.Is(err, errs.NotFound) { + return errs.NewPublicError("rune entries not found") + } return errors.Wrap(err, "error during GetRuneEntryByRuneIdBatch") } - groupedBalances := lo.GroupBy(outPointBalances, func(outPointBalance *entity.OutPointBalance) wire.OutPoint { - return outPointBalance.OutPoint - }) - - utxoList := make([]utxo, 0, len(groupedBalances)) - for outPoint, balances := range groupedBalances { - runeBalances := make([]runeBalance, 0, len(balances)) - for _, balance := range balances { + utxoRespList := make([]utxoItem, 0, len(utxos)) + for _, utxo := range utxos { + runeBalances := make([]runeBalance, 0, len(utxo.RuneBalances)) + for _, balance := range utxo.RuneBalances { runeEntry := runeEntries[balance.RuneId] runeBalances = append(runeBalances, runeBalance{ RuneId: balance.RuneId, @@ -111,34 +147,19 @@ func (h *HttpHandler) GetUTXOsByAddress(ctx *fiber.Ctx) (err error) { }) } - utxoList = append(utxoList, utxo{ - TxHash: outPoint.Hash, - OutputIndex: outPoint.Index, + utxoRespList = append(utxoRespList, utxoItem{ + TxHash: utxo.OutPoint.Hash, + OutputIndex: utxo.OutPoint.Index, Extend: utxoExtend{ Runes: runeBalances, }, }) } - // filter by req.Id if exists - { - runeId, ok := h.resolveRuneId(ctx.UserContext(), req.Id) - if ok { - utxoList = lo.Filter(utxoList, func(u utxo, _ int) bool { - for _, runeBalance := range u.Extend.Runes { - if runeBalance.RuneId == runeId { - return true - } - } - return false - }) - } - } - - resp := getUTXOsByAddressResponse{ - Result: &getUTXOsByAddressResult{ + resp := getUTXOsResponse{ + Result: &getUTXOsResult{ BlockHeight: blockHeight, - List: utxoList, + List: utxoRespList, }, } diff --git a/modules/runes/api/httphandler/routes.go b/modules/runes/api/httphandler/routes.go index da24f353..a6f57103 100644 --- a/modules/runes/api/httphandler/routes.go +++ b/modules/runes/api/httphandler/routes.go @@ -7,12 +7,12 @@ import ( func (h *HttpHandler) Mount(router fiber.Router) error { r := router.Group("/v2/runes") - r.Post("/balances/wallet/batch", h.GetBalancesByAddressBatch) - r.Get("/balances/wallet/:wallet", h.GetBalancesByAddress) + r.Post("/balances/wallet/batch", h.GetBalancesBatch) + r.Get("/balances/wallet/:wallet", h.GetBalances) r.Get("/transactions", h.GetTransactions) r.Get("/holders/:id", h.GetHolders) r.Get("/info/:id", h.GetTokenInfo) - r.Get("/utxos/wallet/:wallet", h.GetUTXOsByAddress) + r.Get("/utxos/wallet/:wallet", h.GetUTXOs) r.Get("/block", h.GetCurrentBlock) 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 a8b14697..7fb8d31f 100644 --- a/modules/runes/database/postgresql/migrations/000001_initialize_tables.up.sql +++ b/modules/runes/database/postgresql/migrations/000001_initialize_tables.up.sql @@ -118,5 +118,7 @@ CREATE TABLE IF NOT EXISTS "runes_balances" ( "amount" DECIMAL NOT NULL, PRIMARY KEY ("pkscript", "rune_id", "block_height") ); +CREATE INDEX IF NOT EXISTS runes_balances_rune_id_block_height_idx ON "runes_balances" USING BTREE ("rune_id", "block_height"); +CREATE INDEX IF NOT EXISTS runes_balances_pkscript_block_height_idx ON "runes_balances" USING BTREE ("pkscript", "block_height"); COMMIT; diff --git a/modules/runes/database/postgresql/queries/data.sql b/modules/runes/database/postgresql/queries/data.sql index aae75f50..591df434 100644 --- a/modules/runes/database/postgresql/queries/data.sql +++ b/modules/runes/database/postgresql/queries/data.sql @@ -2,13 +2,13 @@ WITH balances AS ( SELECT DISTINCT ON (rune_id) * FROM runes_balances WHERE pkscript = $1 AND block_height <= $2 ORDER BY rune_id, block_height DESC ) -SELECT * FROM balances WHERE amount > 0; +SELECT * FROM balances WHERE amount > 0 ORDER BY amount DESC, rune_id LIMIT $3 OFFSET $4; -- name: GetBalancesByRuneId :many WITH balances AS ( SELECT DISTINCT ON (pkscript) * FROM runes_balances WHERE rune_id = $1 AND block_height <= $2 ORDER BY pkscript, block_height DESC ) -SELECT * FROM balances WHERE amount > 0; +SELECT * FROM balances WHERE amount > 0 ORDER BY amount DESC, pkscript LIMIT $3 OFFSET $4; -- 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; @@ -16,8 +16,28 @@ SELECT * FROM runes_balances WHERE pkscript = $1 AND rune_id = $2 AND block_heig -- name: GetOutPointBalancesAtOutPoint :many SELECT * FROM runes_outpoint_balances WHERE tx_hash = $1 AND tx_idx = $2; --- name: GetUnspentOutPointBalancesByPkScript :many -SELECT * FROM runes_outpoint_balances WHERE pkscript = @pkScript AND block_height <= @block_height AND (spent_height IS NULL OR spent_height > @block_height); +-- name: GetRunesUTXOsByPkScript :many +SELECT tx_hash, tx_idx, max("pkscript") as pkscript, array_agg("rune_id") as rune_ids, array_agg("amount") as amounts + FROM runes_outpoint_balances + WHERE + pkscript = @pkScript AND + block_height <= @block_height AND + (spent_height IS NULL OR spent_height > @block_height) + GROUP BY tx_hash, tx_idx + ORDER BY tx_hash, tx_idx + LIMIT $1 OFFSET $2; + +-- name: GetRunesUTXOsByRuneIdAndPkScript :many +SELECT tx_hash, tx_idx, max("pkscript") as pkscript, array_agg("rune_id") as rune_ids, array_agg("amount") as amounts + FROM runes_outpoint_balances + WHERE + pkscript = @pkScript AND + block_height <= @block_height AND + (spent_height IS NULL OR spent_height > @block_height) + GROUP BY tx_hash, tx_idx + HAVING array_agg("rune_id") @> @rune_ids::text[] + ORDER BY tx_hash, tx_idx + LIMIT $1 OFFSET $2; -- name: GetRuneEntriesByRuneIds :many WITH states AS ( @@ -57,7 +77,7 @@ SELECT * FROM runes_transactions ) AND ( @from_block <= runes_transactions.block_height AND runes_transactions.block_height <= @to_block ) -ORDER BY runes_transactions.block_height DESC LIMIT 10000; +ORDER BY runes_transactions.block_height DESC, runes_transactions.index DESC LIMIT $1 OFFSET $2; -- name: CountRuneEntries :one SELECT COUNT(*) FROM runes_entries; diff --git a/modules/runes/datagateway/runes.go b/modules/runes/datagateway/runes.go index f731b438..c822bafa 100644 --- a/modules/runes/datagateway/runes.go +++ b/modules/runes/datagateway/runes.go @@ -27,10 +27,11 @@ type RunesReaderDataGateway interface { GetLatestBlock(ctx context.Context) (types.BlockHeader, error) GetIndexedBlockByHeight(ctx context.Context, height int64) (*entity.IndexedBlock, error) // GetRuneTransactions returns the runes transactions, filterable by pkScript, runeId and height. If pkScript, runeId or height is zero value, that filter is ignored. - GetRuneTransactions(ctx context.Context, pkScript []byte, runeId runes.RuneId, fromBlock, toBlock uint64) ([]*entity.RuneTransaction, error) + GetRuneTransactions(ctx context.Context, pkScript []byte, runeId runes.RuneId, fromBlock, toBlock uint64, limit int32, offset int32) ([]*entity.RuneTransaction, error) GetRunesBalancesAtOutPoint(ctx context.Context, outPoint wire.OutPoint) (map[runes.RuneId]*entity.OutPointBalance, error) - GetUnspentOutPointBalancesByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64) ([]*entity.OutPointBalance, error) + GetRunesUTXOsByRuneIdAndPkScript(ctx context.Context, runeId runes.RuneId, pkScript []byte, blockHeight uint64, limit int32, offset int32) ([]*entity.RunesUTXO, error) + GetRunesUTXOsByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64, limit int32, offset int32) ([]*entity.RunesUTXO, error) // GetRuneIdFromRune returns the RuneId for the given rune. Returns errs.NotFound if the rune entry is not found. GetRuneIdFromRune(ctx context.Context, rune runes.Rune) (runes.RuneId, error) // GetRuneEntryByRuneId returns the RuneEntry for the given runeId. Returns errs.NotFound if the rune entry is not found. @@ -45,10 +46,12 @@ type RunesReaderDataGateway interface { CountRuneEntries(ctx context.Context) (uint64, error) // GetBalancesByPkScript returns the balances for the given pkScript at the given blockHeight. - GetBalancesByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64) (map[runes.RuneId]*entity.Balance, error) + // Use limit = -1 as no limit. + GetBalancesByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64, limit int32, offset int32) ([]*entity.Balance, error) // GetBalancesByRuneId returns the balances for the given runeId at the given blockHeight. // Cannot use []byte as map key, so we're returning as slice. - GetBalancesByRuneId(ctx context.Context, runeId runes.RuneId, blockHeight uint64) ([]*entity.Balance, error) + // Use limit = -1 as no limit. + 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) } diff --git a/modules/runes/internal/entity/runes_utxo.go b/modules/runes/internal/entity/runes_utxo.go new file mode 100644 index 00000000..ced5bd8f --- /dev/null +++ b/modules/runes/internal/entity/runes_utxo.go @@ -0,0 +1,18 @@ +package entity + +import ( + "github.com/btcsuite/btcd/wire" + "github.com/gaze-network/indexer-network/modules/runes/runes" + "github.com/gaze-network/uint128" +) + +type RunesUTXOBalance struct { + RuneId runes.RuneId + Amount uint128.Uint128 +} + +type RunesUTXO struct { + PkScript []byte + OutPoint wire.OutPoint + RuneBalances []RunesUTXOBalance +} diff --git a/modules/runes/repository/postgres/gen/data.sql.go b/modules/runes/repository/postgres/gen/data.sql.go index b510f6e8..6dd729f6 100644 --- a/modules/runes/repository/postgres/gen/data.sql.go +++ b/modules/runes/repository/postgres/gen/data.sql.go @@ -296,12 +296,14 @@ const getBalancesByPkScript = `-- name: GetBalancesByPkScript :many WITH balances AS ( SELECT DISTINCT ON (rune_id) pkscript, block_height, rune_id, amount FROM runes_balances WHERE pkscript = $1 AND block_height <= $2 ORDER BY rune_id, block_height DESC ) -SELECT pkscript, block_height, rune_id, amount FROM balances WHERE amount > 0 +SELECT pkscript, block_height, rune_id, amount FROM balances WHERE amount > 0 ORDER BY amount DESC, rune_id LIMIT $3 OFFSET $4 ` type GetBalancesByPkScriptParams struct { Pkscript string BlockHeight int32 + Limit int32 + Offset int32 } type GetBalancesByPkScriptRow struct { @@ -312,7 +314,12 @@ type GetBalancesByPkScriptRow struct { } func (q *Queries) GetBalancesByPkScript(ctx context.Context, arg GetBalancesByPkScriptParams) ([]GetBalancesByPkScriptRow, error) { - rows, err := q.db.Query(ctx, getBalancesByPkScript, arg.Pkscript, arg.BlockHeight) + rows, err := q.db.Query(ctx, getBalancesByPkScript, + arg.Pkscript, + arg.BlockHeight, + arg.Limit, + arg.Offset, + ) if err != nil { return nil, err } @@ -340,12 +347,14 @@ const getBalancesByRuneId = `-- name: GetBalancesByRuneId :many WITH balances AS ( SELECT DISTINCT ON (pkscript) pkscript, block_height, rune_id, amount FROM runes_balances WHERE rune_id = $1 AND block_height <= $2 ORDER BY pkscript, block_height DESC ) -SELECT pkscript, block_height, rune_id, amount FROM balances WHERE amount > 0 +SELECT pkscript, block_height, rune_id, amount FROM balances WHERE amount > 0 ORDER BY amount DESC, pkscript LIMIT $3 OFFSET $4 ` type GetBalancesByRuneIdParams struct { RuneID string BlockHeight int32 + Limit int32 + Offset int32 } type GetBalancesByRuneIdRow struct { @@ -356,7 +365,12 @@ type GetBalancesByRuneIdRow struct { } func (q *Queries) GetBalancesByRuneId(ctx context.Context, arg GetBalancesByRuneIdParams) ([]GetBalancesByRuneIdRow, error) { - rows, err := q.db.Query(ctx, getBalancesByRuneId, arg.RuneID, arg.BlockHeight) + rows, err := q.db.Query(ctx, getBalancesByRuneId, + arg.RuneID, + arg.BlockHeight, + arg.Limit, + arg.Offset, + ) if err != nil { return nil, err } @@ -635,23 +649,25 @@ const getRuneTransactions = `-- name: GetRuneTransactions :many SELECT hash, runes_transactions.block_height, index, timestamp, inputs, outputs, mints, burns, rune_etched, tx_hash, runes_runestones.block_height, etching, etching_divisibility, etching_premine, etching_rune, etching_spacers, etching_symbol, etching_terms, etching_terms_amount, etching_terms_cap, etching_terms_height_start, etching_terms_height_end, etching_terms_offset_start, etching_terms_offset_end, etching_turbo, edicts, mint, pointer, cenotaph, flaws FROM runes_transactions LEFT JOIN runes_runestones ON runes_transactions.hash = runes_runestones.tx_hash WHERE ( - $1::BOOLEAN = FALSE -- if @filter_pk_script is TRUE, apply pk_script filter - OR runes_transactions.outputs @> $2::JSONB - OR runes_transactions.inputs @> $2::JSONB - ) AND ( - $3::BOOLEAN = FALSE -- if @filter_rune_id is TRUE, apply rune_id filter + $3::BOOLEAN = FALSE -- if @filter_pk_script is TRUE, apply pk_script filter OR runes_transactions.outputs @> $4::JSONB - OR runes_transactions.inputs @> $4::JSONB - OR runes_transactions.mints ? $5 - OR runes_transactions.burns ? $5 - OR (runes_transactions.rune_etched = TRUE AND runes_transactions.block_height = $6 AND runes_transactions.index = $7) + OR runes_transactions.inputs @> $4::JSONB ) AND ( - $8 <= runes_transactions.block_height AND runes_transactions.block_height <= $9 + $5::BOOLEAN = FALSE -- if @filter_rune_id is TRUE, apply rune_id filter + OR runes_transactions.outputs @> $6::JSONB + OR runes_transactions.inputs @> $6::JSONB + OR runes_transactions.mints ? $7 + OR runes_transactions.burns ? $7 + OR (runes_transactions.rune_etched = TRUE AND runes_transactions.block_height = $8 AND runes_transactions.index = $9) + ) AND ( + $10 <= runes_transactions.block_height AND runes_transactions.block_height <= $11 ) -ORDER BY runes_transactions.block_height DESC LIMIT 10000 +ORDER BY runes_transactions.block_height DESC, runes_transactions.index DESC LIMIT $1 OFFSET $2 ` type GetRuneTransactionsParams struct { + Limit int32 + Offset int32 FilterPkScript bool PkScriptParam []byte FilterRuneID bool @@ -698,6 +714,8 @@ type GetRuneTransactionsRow struct { func (q *Queries) GetRuneTransactions(ctx context.Context, arg GetRuneTransactionsParams) ([]GetRuneTransactionsRow, error) { rows, err := q.db.Query(ctx, getRuneTransactions, + arg.Limit, + arg.Offset, arg.FilterPkScript, arg.PkScriptParam, arg.FilterRuneID, @@ -757,32 +775,114 @@ func (q *Queries) GetRuneTransactions(ctx context.Context, arg GetRuneTransactio return items, nil } -const getUnspentOutPointBalancesByPkScript = `-- name: GetUnspentOutPointBalancesByPkScript :many -SELECT rune_id, pkscript, tx_hash, tx_idx, amount, block_height, spent_height FROM runes_outpoint_balances WHERE pkscript = $1 AND block_height <= $2 AND (spent_height IS NULL OR spent_height > $2) +const getRunesUTXOsByPkScript = `-- name: GetRunesUTXOsByPkScript :many +SELECT tx_hash, tx_idx, max("pkscript") as pkscript, array_agg("rune_id") as rune_ids, array_agg("amount") as amounts + FROM runes_outpoint_balances + WHERE + pkscript = $3 AND + block_height <= $4 AND + (spent_height IS NULL OR spent_height > $4) + GROUP BY tx_hash, tx_idx + ORDER BY tx_hash, tx_idx + LIMIT $1 OFFSET $2 ` -type GetUnspentOutPointBalancesByPkScriptParams struct { +type GetRunesUTXOsByPkScriptParams struct { + Limit int32 + Offset int32 Pkscript string BlockHeight int32 } -func (q *Queries) GetUnspentOutPointBalancesByPkScript(ctx context.Context, arg GetUnspentOutPointBalancesByPkScriptParams) ([]RunesOutpointBalance, error) { - rows, err := q.db.Query(ctx, getUnspentOutPointBalancesByPkScript, arg.Pkscript, arg.BlockHeight) +type GetRunesUTXOsByPkScriptRow struct { + TxHash string + TxIdx int32 + Pkscript interface{} + RuneIds interface{} + Amounts interface{} +} + +func (q *Queries) GetRunesUTXOsByPkScript(ctx context.Context, arg GetRunesUTXOsByPkScriptParams) ([]GetRunesUTXOsByPkScriptRow, error) { + rows, err := q.db.Query(ctx, getRunesUTXOsByPkScript, + arg.Limit, + arg.Offset, + arg.Pkscript, + arg.BlockHeight, + ) if err != nil { return nil, err } defer rows.Close() - var items []RunesOutpointBalance + var items []GetRunesUTXOsByPkScriptRow for rows.Next() { - var i RunesOutpointBalance + var i GetRunesUTXOsByPkScriptRow if err := rows.Scan( - &i.RuneID, + &i.TxHash, + &i.TxIdx, &i.Pkscript, + &i.RuneIds, + &i.Amounts, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getRunesUTXOsByRuneIdAndPkScript = `-- name: GetRunesUTXOsByRuneIdAndPkScript :many +SELECT tx_hash, tx_idx, max("pkscript") as pkscript, array_agg("rune_id") as rune_ids, array_agg("amount") as amounts + FROM runes_outpoint_balances + WHERE + pkscript = $3 AND + block_height <= $4 AND + (spent_height IS NULL OR spent_height > $4) + GROUP BY tx_hash, tx_idx + HAVING array_agg("rune_id") @> $5::text[] + ORDER BY tx_hash, tx_idx + LIMIT $1 OFFSET $2 +` + +type GetRunesUTXOsByRuneIdAndPkScriptParams struct { + Limit int32 + Offset int32 + Pkscript string + BlockHeight int32 + RuneIds []string +} + +type GetRunesUTXOsByRuneIdAndPkScriptRow struct { + TxHash string + TxIdx int32 + Pkscript interface{} + RuneIds interface{} + Amounts interface{} +} + +func (q *Queries) GetRunesUTXOsByRuneIdAndPkScript(ctx context.Context, arg GetRunesUTXOsByRuneIdAndPkScriptParams) ([]GetRunesUTXOsByRuneIdAndPkScriptRow, error) { + rows, err := q.db.Query(ctx, getRunesUTXOsByRuneIdAndPkScript, + arg.Limit, + arg.Offset, + arg.Pkscript, + arg.BlockHeight, + arg.RuneIds, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetRunesUTXOsByRuneIdAndPkScriptRow + for rows.Next() { + var i GetRunesUTXOsByRuneIdAndPkScriptRow + if err := rows.Scan( &i.TxHash, &i.TxIdx, - &i.Amount, - &i.BlockHeight, - &i.SpentHeight, + &i.Pkscript, + &i.RuneIds, + &i.Amounts, ); err != nil { return nil, err } diff --git a/modules/runes/repository/postgres/mapper.go b/modules/runes/repository/postgres/mapper.go index ef992736..eabbbb59 100644 --- a/modules/runes/repository/postgres/mapper.go +++ b/modules/runes/repository/postgres/mapper.go @@ -638,6 +638,72 @@ func mapIndexedBlockTypeToParams(src entity.IndexedBlock) (gen.CreateIndexedBloc }, nil } +func mapRunesUTXOModelToType(src gen.GetRunesUTXOsByPkScriptRow) (entity.RunesUTXO, error) { + pkScriptRaw, ok := src.Pkscript.(string) + if !ok { + return entity.RunesUTXO{}, errors.New("pkscript from database is not string") + } + pkScript, err := hex.DecodeString(pkScriptRaw) + if err != nil { + return entity.RunesUTXO{}, errors.Wrap(err, "failed to parse pkscript") + } + txHash, err := chainhash.NewHashFromStr(src.TxHash) + if err != nil { + return entity.RunesUTXO{}, errors.Wrap(err, "failed to parse tx hash") + } + runeIdsRaw, ok := src.RuneIds.([]interface{}) + if !ok { + return entity.RunesUTXO{}, errors.New("src.RuneIds is not a slice") + } + runeIds := make([]string, 0, len(runeIdsRaw)) + for i, raw := range runeIdsRaw { + runeId, ok := raw.(string) + if !ok { + return entity.RunesUTXO{}, errors.Errorf("src.RuneIds[%d] is not a string", i) + } + runeIds = append(runeIds, runeId) + } + amountsRaw, ok := src.Amounts.([]interface{}) + if !ok { + return entity.RunesUTXO{}, errors.New("amounts from database is not a slice") + } + amounts := make([]pgtype.Numeric, 0, len(amountsRaw)) + for i, raw := range amountsRaw { + amount, ok := raw.(pgtype.Numeric) + if !ok { + return entity.RunesUTXO{}, errors.Errorf("src.Amounts[%d] is not pgtype.Numeric", i) + } + amounts = append(amounts, amount) + } + if len(runeIds) != len(amounts) { + return entity.RunesUTXO{}, errors.New("rune ids and amounts have different lengths") + } + + runesBalances := make([]entity.RunesUTXOBalance, 0, len(runeIds)) + for i := range runeIds { + runeId, err := runes.NewRuneIdFromString(runeIds[i]) + if err != nil { + return entity.RunesUTXO{}, errors.Wrap(err, "failed to parse rune id") + } + amount, err := uint128FromNumeric(amounts[i]) + if err != nil { + return entity.RunesUTXO{}, errors.Wrap(err, "failed to parse amount") + } + runesBalances = append(runesBalances, entity.RunesUTXOBalance{ + RuneId: runeId, + Amount: lo.FromPtr(amount), + }) + } + return entity.RunesUTXO{ + PkScript: pkScript, + OutPoint: wire.OutPoint{ + Hash: *txHash, + Index: uint32(src.TxIdx), + }, + RuneBalances: runesBalances, + }, nil +} + func mapOutPointBalanceModelToType(src gen.RunesOutpointBalance) (entity.OutPointBalance, error) { runeId, err := runes.NewRuneIdFromString(src.RuneID) if err != nil { diff --git a/modules/runes/repository/postgres/runes.go b/modules/runes/repository/postgres/runes.go index 318a9060..2e79a21c 100644 --- a/modules/runes/repository/postgres/runes.go +++ b/modules/runes/repository/postgres/runes.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "fmt" + "math" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" @@ -62,7 +63,18 @@ func (r *Repository) GetIndexedBlockByHeight(ctx context.Context, height int64) return indexedBlock, nil } -func (r *Repository) GetRuneTransactions(ctx context.Context, pkScript []byte, runeId runes.RuneId, fromBlock, toBlock uint64) ([]*entity.RuneTransaction, error) { +const maxRuneTransactionsLimit = 10000 // temporary limit to prevent large queries from overwhelming the database + +func (r *Repository) GetRuneTransactions(ctx context.Context, pkScript []byte, runeId runes.RuneId, fromBlock, toBlock uint64, limit int32, offset int32) ([]*entity.RuneTransaction, error) { + if limit == -1 { + limit = maxRuneTransactionsLimit + } + if limit < 0 { + return nil, errors.Wrap(errs.InvalidArgument, "limit must be -1 or non-negative") + } + if limit > maxRuneTransactionsLimit { + return nil, errors.Wrapf(errs.InvalidArgument, "limit cannot exceed %d", maxRuneTransactionsLimit) + } pkScriptParam := []byte(fmt.Sprintf(`[{"pkScript":"%s"}]`, hex.EncodeToString(pkScript))) runeIdParam := []byte(fmt.Sprintf(`[{"runeId":"%s"}]`, runeId.String())) rows, err := r.queries.GetRuneTransactions(ctx, gen.GetRuneTransactionsParams{ @@ -77,6 +89,9 @@ func (r *Repository) GetRuneTransactions(ctx context.Context, pkScript []byte, r FromBlock: int32(fromBlock), ToBlock: int32(toBlock), + + Limit: limit, + Offset: offset, }) if err != nil { return nil, errors.Wrap(err, "error during query") @@ -125,22 +140,59 @@ func (r *Repository) GetRunesBalancesAtOutPoint(ctx context.Context, outPoint wi return result, nil } -func (r *Repository) GetUnspentOutPointBalancesByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64) ([]*entity.OutPointBalance, error) { - balances, err := r.queries.GetUnspentOutPointBalancesByPkScript(ctx, gen.GetUnspentOutPointBalancesByPkScriptParams{ +func (r *Repository) GetRunesUTXOsByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64, limit int32, offset int32) ([]*entity.RunesUTXO, error) { + if limit == -1 { + limit = math.MaxInt32 + } + if limit < 0 { + return nil, errors.Wrap(errs.InvalidArgument, "limit must be -1 or non-negative") + } + rows, err := r.queries.GetRunesUTXOsByPkScript(ctx, gen.GetRunesUTXOsByPkScriptParams{ Pkscript: hex.EncodeToString(pkScript), BlockHeight: int32(blockHeight), + Limit: limit, + Offset: offset, }) if err != nil { return nil, errors.Wrap(err, "error during query") } - result := make([]*entity.OutPointBalance, 0, len(balances)) - for _, balanceModel := range balances { - balance, err := mapOutPointBalanceModelToType(balanceModel) + result := make([]*entity.RunesUTXO, 0, len(rows)) + for _, row := range rows { + utxo, err := mapRunesUTXOModelToType(row) if err != nil { - return nil, errors.Wrap(err, "failed to parse balance model") + return nil, errors.Wrap(err, "failed to parse row model") } - result = append(result, &balance) + result = append(result, &utxo) + } + return result, nil +} + +func (r *Repository) GetRunesUTXOsByRuneIdAndPkScript(ctx context.Context, runeId runes.RuneId, pkScript []byte, blockHeight uint64, limit int32, offset int32) ([]*entity.RunesUTXO, error) { + if limit == -1 { + limit = math.MaxInt32 + } + if limit < 0 { + return nil, errors.Wrap(errs.InvalidArgument, "limit must be -1 or non-negative") + } + rows, err := r.queries.GetRunesUTXOsByRuneIdAndPkScript(ctx, gen.GetRunesUTXOsByRuneIdAndPkScriptParams{ + Pkscript: hex.EncodeToString(pkScript), + BlockHeight: int32(blockHeight), + RuneIds: []string{runeId.String()}, + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, errors.Wrap(err, "error during query") + } + + result := make([]*entity.RunesUTXO, 0, len(rows)) + for _, row := range rows { + utxo, err := mapRunesUTXOModelToType(gen.GetRunesUTXOsByPkScriptRow(row)) + if err != nil { + return nil, errors.Wrap(err, "failed to parse row") + } + result = append(result, &utxo) } return result, nil } @@ -245,30 +297,46 @@ func (r *Repository) CountRuneEntries(ctx context.Context) (uint64, error) { return uint64(count), nil } -func (r *Repository) GetBalancesByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64) (map[runes.RuneId]*entity.Balance, error) { +func (r *Repository) GetBalancesByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64, limit int32, offset int32) ([]*entity.Balance, error) { + if limit == -1 { + limit = math.MaxInt32 + } + if limit < 0 { + return nil, errors.Wrap(errs.InvalidArgument, "limit must be -1 or non-negative") + } balances, err := r.queries.GetBalancesByPkScript(ctx, gen.GetBalancesByPkScriptParams{ Pkscript: hex.EncodeToString(pkScript), BlockHeight: int32(blockHeight), + Limit: limit, + Offset: offset, }) if err != nil { return nil, errors.Wrap(err, "error during query") } - result := make(map[runes.RuneId]*entity.Balance, len(balances)) + result := make([]*entity.Balance, 0, len(balances)) for _, balanceModel := range balances { balance, err := mapBalanceModelToType(gen.RunesBalance(balanceModel)) if err != nil { return nil, errors.Wrap(err, "failed to parse balance model") } - result[balance.RuneId] = balance + result = append(result, balance) } return result, nil } -func (r *Repository) GetBalancesByRuneId(ctx context.Context, runeId runes.RuneId, blockHeight uint64) ([]*entity.Balance, error) { +func (r *Repository) GetBalancesByRuneId(ctx context.Context, runeId runes.RuneId, blockHeight uint64, limit int32, offset int32) ([]*entity.Balance, error) { + if limit == -1 { + limit = math.MaxInt32 + } + if limit < 0 { + return nil, errors.Wrap(errs.InvalidArgument, "limit must be -1 or non-negative") + } balances, err := r.queries.GetBalancesByRuneId(ctx, gen.GetBalancesByRuneIdParams{ RuneID: runeId.String(), BlockHeight: int32(blockHeight), + Limit: limit, + Offset: offset, }) if err != nil { return nil, errors.Wrap(err, "error during query") diff --git a/modules/runes/runes/rune.go b/modules/runes/runes/rune.go index 5ccea7bb..213257bb 100644 --- a/modules/runes/runes/rune.go +++ b/modules/runes/runes/rune.go @@ -29,6 +29,10 @@ var ErrInvalidBase26 = errors.New("invalid base-26 character: must be in the ran func NewRuneFromString(value string) (Rune, error) { n := uint128.From64(0) for i, char := range value { + // skip spacers + if char == '.' || char == '•' { + continue + } if i > 0 { n = n.Add(uint128.From64(1)) } diff --git a/modules/runes/usecase/get_balances.go b/modules/runes/usecase/get_balances.go index d02130ab..838f80e4 100644 --- a/modules/runes/usecase/get_balances.go +++ b/modules/runes/usecase/get_balances.go @@ -8,16 +8,18 @@ import ( "github.com/gaze-network/indexer-network/modules/runes/runes" ) -func (u *Usecase) GetBalancesByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64) (map[runes.RuneId]*entity.Balance, error) { - balances, err := u.runesDg.GetBalancesByPkScript(ctx, pkScript, blockHeight) +// Use limit = -1 as no limit. +func (u *Usecase) GetBalancesByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64, limit int32, offset int32) ([]*entity.Balance, error) { + balances, err := u.runesDg.GetBalancesByPkScript(ctx, pkScript, blockHeight, limit, offset) if err != nil { return nil, errors.Wrap(err, "error during GetBalancesByPkScript") } return balances, nil } -func (u *Usecase) GetBalancesByRuneId(ctx context.Context, runeId runes.RuneId, blockHeight uint64) ([]*entity.Balance, error) { - balances, err := u.runesDg.GetBalancesByRuneId(ctx, runeId, blockHeight) +// Use limit = -1 as no limit. +func (u *Usecase) GetBalancesByRuneId(ctx context.Context, runeId runes.RuneId, blockHeight uint64, limit int32, offset int32) ([]*entity.Balance, error) { + balances, err := u.runesDg.GetBalancesByRuneId(ctx, runeId, blockHeight, limit, offset) if err != nil { return nil, errors.Wrap(err, "failed to get rune holders by rune id") } diff --git a/modules/runes/usecase/get_outpoint_balances.go b/modules/runes/usecase/get_outpoint_balances.go deleted file mode 100644 index 90161197..00000000 --- a/modules/runes/usecase/get_outpoint_balances.go +++ /dev/null @@ -1,16 +0,0 @@ -package usecase - -import ( - "context" - - "github.com/cockroachdb/errors" - "github.com/gaze-network/indexer-network/modules/runes/internal/entity" -) - -func (u *Usecase) GetUnspentOutPointBalancesByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64) ([]*entity.OutPointBalance, error) { - balances, err := u.runesDg.GetUnspentOutPointBalancesByPkScript(ctx, pkScript, blockHeight) - if err != nil { - return nil, errors.Wrap(err, "error during GetBalancesByPkScript") - } - return balances, nil -} diff --git a/modules/runes/usecase/get_transactions.go b/modules/runes/usecase/get_transactions.go index 8cc1932b..88cee15e 100644 --- a/modules/runes/usecase/get_transactions.go +++ b/modules/runes/usecase/get_transactions.go @@ -8,8 +8,9 @@ import ( "github.com/gaze-network/indexer-network/modules/runes/runes" ) -func (u *Usecase) GetRuneTransactions(ctx context.Context, pkScript []byte, runeId runes.RuneId, fromBlock, toBlock uint64) ([]*entity.RuneTransaction, error) { - txs, err := u.runesDg.GetRuneTransactions(ctx, pkScript, runeId, fromBlock, toBlock) +// Use limit = -1 as no limit. +func (u *Usecase) GetRuneTransactions(ctx context.Context, pkScript []byte, runeId runes.RuneId, fromBlock, toBlock uint64, limit int32, offset int32) ([]*entity.RuneTransaction, error) { + txs, err := u.runesDg.GetRuneTransactions(ctx, pkScript, runeId, fromBlock, toBlock, limit, offset) if err != nil { return nil, errors.Wrap(err, "error during GetTransactionsByHeight") } diff --git a/modules/runes/usecase/get_utxos.go b/modules/runes/usecase/get_utxos.go new file mode 100644 index 00000000..22dec11d --- /dev/null +++ b/modules/runes/usecase/get_utxos.go @@ -0,0 +1,25 @@ +package usecase + +import ( + "context" + + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/modules/runes/internal/entity" + "github.com/gaze-network/indexer-network/modules/runes/runes" +) + +func (u *Usecase) GetRunesUTXOsByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64, limit int32, offset int32) ([]*entity.RunesUTXO, error) { + balances, err := u.runesDg.GetRunesUTXOsByPkScript(ctx, pkScript, blockHeight, limit, offset) + if err != nil { + return nil, errors.Wrap(err, "error during GetBalancesByPkScript") + } + return balances, nil +} + +func (u *Usecase) GetRunesUTXOsByRuneIdAndPkScript(ctx context.Context, runeId runes.RuneId, pkScript []byte, blockHeight uint64, limit int32, offset int32) ([]*entity.RunesUTXO, error) { + balances, err := u.runesDg.GetRunesUTXOsByRuneIdAndPkScript(ctx, runeId, pkScript, blockHeight, limit, offset) + if err != nil { + return nil, errors.Wrap(err, "error during GetBalancesByPkScript") + } + return balances, nil +}