diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..e69de29 diff --git a/db/nft.go b/db/nft.go index 2a5f64f..953d73c 100644 --- a/db/nft.go +++ b/db/nft.go @@ -276,31 +276,61 @@ func GetNftEvents(conn *pgxpool.Conn, q QueryEventsRequest, p PageRequest) (Quer e.id, e.action, e.class_id, e.nft_id, e.sender, e.receiver, e.timestamp, e.tx_hash, e.events, e.price, e.memo - FROM nft_event as e - JOIN nft_class as c - ON e.class_id = c.class_id - JOIN iscn AS i - ON i.iscn_id_prefix = c.parent_iscn_id_prefix - JOIN iscn_latest_version - ON i.iscn_id_prefix = iscn_latest_version.iscn_id_prefix - AND i.version = iscn_latest_version.latest_version - WHERE ($4 = '' OR e.class_id = $4) - AND ($12::text[] IS NULL OR cardinality($12::text[]) = 0 OR i.owner = ANY($12)) - AND (nft_id = '' OR $5 = '' OR nft_id = $5) - AND ($6 = '' OR c.parent_iscn_id_prefix = $6) - AND ($10::text[] IS NULL OR cardinality($10::text[]) = 0 OR e.sender = ANY($10)) - AND ($11::text[] IS NULL OR cardinality($11::text[]) = 0 OR e.receiver = ANY($11)) - AND ($13::text[] IS NULL OR cardinality($13::text[]) = 0 - OR e.sender = ANY($13) - OR e.receiver = ANY($13) - OR i.owner = ANY($13) + FROM ( + ( + SELECT DISTINCT ON (e.id) e.* + FROM nft_event as e + JOIN nft_class as c + ON e.class_id = c.class_id + JOIN iscn AS i + ON i.iscn_id_prefix = c.parent_iscn_id_prefix + JOIN iscn_latest_version + ON i.iscn_id_prefix = iscn_latest_version.iscn_id_prefix + AND i.version = iscn_latest_version.latest_version + WHERE ($4 = '' OR e.class_id = $4) + AND ($12::text[] IS NULL OR cardinality($12::text[]) = 0 OR i.owner = ANY($12)) + AND (nft_id = '' OR $5 = '' OR nft_id = $5) + AND ($6 = '' OR c.parent_iscn_id_prefix = $6) + AND ($10::text[] IS NULL OR cardinality($10::text[]) = 0 OR e.sender = ANY($10)) + AND ($11::text[] IS NULL OR cardinality($11::text[]) = 0 OR e.receiver = ANY($11)) + AND ($13::text[] IS NULL OR cardinality($13::text[]) = 0 + OR e.sender = ANY($13) + OR e.receiver = ANY($13) + ) + AND ($1 = 0 OR e.id > $1) + AND ($2 = 0 OR e.id < $2) + AND ($7::text[] IS NULL OR cardinality($7::text[]) = 0 OR e.action = ANY($7)) + AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0 OR e.sender != ALL($8)) + AND ($9::text[] IS NULL OR cardinality($9::text[]) = 0 OR e.receiver != ALL($9)) + ORDER BY e.id %[1]s + LIMIT $3 + ) UNION ALL ( + SELECT DISTINCT ON (e.id) e.* + FROM nft_event as e + JOIN nft_class as c + ON e.class_id = c.class_id + JOIN iscn AS i + ON i.iscn_id_prefix = c.parent_iscn_id_prefix + JOIN iscn_latest_version + ON i.iscn_id_prefix = iscn_latest_version.iscn_id_prefix + AND i.version = iscn_latest_version.latest_version + WHERE ($4 = '' OR e.class_id = $4) + AND ($12::text[] IS NULL OR cardinality($12::text[]) = 0 OR i.owner = ANY($12)) + AND (nft_id = '' OR $5 = '' OR nft_id = $5) + AND ($6 = '' OR c.parent_iscn_id_prefix = $6) + AND ($10::text[] IS NULL OR cardinality($10::text[]) = 0 OR e.sender = ANY($10)) + AND ($11::text[] IS NULL OR cardinality($11::text[]) = 0 OR e.receiver = ANY($11)) + AND ($13::text[] IS NULL OR cardinality($13::text[]) = 0 OR i.owner = ANY($13)) + AND ($1 = 0 OR e.id > $1) + AND ($2 = 0 OR e.id < $2) + AND ($7::text[] IS NULL OR cardinality($7::text[]) = 0 OR e.action = ANY($7)) + AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0 OR e.sender != ALL($8)) + AND ($9::text[] IS NULL OR cardinality($9::text[]) = 0 OR e.receiver != ALL($9)) + ORDER BY e.id %[1]s + LIMIT $3 ) - AND ($1 = 0 OR e.id > $1) - AND ($2 = 0 OR e.id < $2) - AND ($7::text[] IS NULL OR cardinality($7::text[]) = 0 OR e.action = ANY($7)) - AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0 OR e.sender != ALL($8)) - AND ($9::text[] IS NULL OR cardinality($9::text[]) = 0 OR e.receiver != ALL($9)) - ORDER BY e.id %s + ) AS e + ORDER BY e.id %[1]s LIMIT $3 `, p.Order()) @@ -352,6 +382,60 @@ func GetNftEvents(conn *pgxpool.Conn, q QueryEventsRequest, p PageRequest) (Quer func GetNftIncomes(conn *pgxpool.Conn, q QueryIncomesRequest, p PageRequest) (QueryIncomesResponse, error) { ownerVariations := utils.ConvertAddressPrefixes(q.Owner, AddressPrefixes) stakeholderVariations := utils.ConvertAddressPrefixes(q.Address, AddressPrefixes) + + sql := fmt.Sprintf(` + SELECT i.address, SUM(i.amount) AS amount + FROM nft_event AS e + JOIN nft_income AS i + ON e.class_id = i.class_id + AND e.nft_id = i.nft_id + AND e.tx_hash = i.tx_hash + WHERE ($2 = 0 OR i.id > $2) + AND ($3 = 0 OR i.id < $3) + AND ($4 = '' OR e.class_id = $4) + AND ($5 = '' OR e.nft_id = $5) + AND ($6::text[] IS NULL OR cardinality($6::text[]) = 0 OR e.receiver = ANY($6)) + AND ($7::text[] IS NULL OR cardinality($7::text[]) = 0 OR i.address = ANY($7)) + AND ($8 = 0 OR (e.timestamp IS NOT NULL AND e.timestamp > to_timestamp($8))) + AND ($9 = 0 OR (e.timestamp IS NOT NULL AND e.timestamp < to_timestamp($9))) + AND ($10::text[] IS NULL OR cardinality($10::text[]) = 0 OR e.action = ANY($10)) + GROUP BY i.address + ORDER BY amount %[1]s + LIMIT $1 + `, p.Order()) + + ctx, cancel := GetTimeoutContext() + defer cancel() + + rows, err := conn.Query( + ctx, sql, + p.Limit, p.After(), p.Before(), q.ClassId, q.NftId, + ownerVariations, stakeholderVariations, q.After, q.Before, q.ActionType, + ) + if err != nil { + logger.L.Errorw("Failed to query nft incomes", "error", err) + return QueryIncomesResponse{}, fmt.Errorf("query nft incomes error: %w", err) + } + + res := QueryIncomesResponse{ + Incomes: make([]NftIncomeResponse, 0), + } + for rows.Next() { + var r NftIncomeResponse + if err = rows.Scan(&r.Address, &r.Amount); err != nil { + logger.L.Errorw("failed to scan nft incomes", "error", err, "q", q) + return QueryIncomesResponse{}, fmt.Errorf("query nft incomes data failed: %w", err) + } + res.Incomes = append(res.Incomes, r) + res.TotalAmount += r.Amount + } + res.Pagination.Count = len(res.Incomes) + return res, nil +} + +func GetNftIncomeDetails(conn *pgxpool.Conn, q QueryIncomeDetailsRequest, p PageRequest) (QueryIncomeDetailsResponse, error) { + ownerVariations := utils.ConvertAddressPrefixes(q.Owner, AddressPrefixes) + stakeholderVariations := utils.ConvertAddressPrefixes(q.Address, AddressPrefixes) orderBy := "i.id" switch q.OrderBy { case "price": @@ -393,25 +477,25 @@ func GetNftIncomes(conn *pgxpool.Conn, q QueryIncomesRequest, p PageRequest) (Qu ownerVariations, stakeholderVariations, q.After, q.Before, q.ActionType, ) if err != nil { - logger.L.Errorw("Failed to query nft incomes", "error", err) - return QueryIncomesResponse{}, fmt.Errorf("query nft royalties error: %w", err) + logger.L.Errorw("Failed to query nft income details", "error", err) + return QueryIncomeDetailsResponse{}, fmt.Errorf("query nft income details error: %w", err) } - res := QueryIncomesResponse{ - Incomes: make([]NftIncomeResponse, 0), + res := QueryIncomeDetailsResponse{ + IncomeDetails: make([]NftIncomeDetailResponse, 0), } for rows.Next() { - var r NftIncomeResponse + var r NftIncomeDetailResponse if err = rows.Scan( &r.ClassId, &r.NftId, &r.TxHash, &r.Timestamp, &r.Owner, &r.Address, &r.Price, &r.Amount, ); err != nil { - logger.L.Errorw("failed to scan nft incomes", "error", err, "q", q) - return QueryIncomesResponse{}, fmt.Errorf("query nft incomes data failed: %w", err) + logger.L.Errorw("failed to scan nft income details", "error", err, "q", q) + return QueryIncomeDetailsResponse{}, fmt.Errorf("query nft income details data failed: %w", err) } - res.Incomes = append(res.Incomes, r) + res.IncomeDetails = append(res.IncomeDetails, r) } - res.Pagination.Count = len(res.Incomes) + res.Pagination.Count = len(res.IncomeDetails) return res, nil } diff --git a/db/schema/v015.sql b/db/schema/v015.sql new file mode 100644 index 0000000..a975725 --- /dev/null +++ b/db/schema/v015.sql @@ -0,0 +1 @@ +CREATE INDEX idx_nft_event_class_id ON nft_event (class_id); diff --git a/db/types.go b/db/types.go index 6cd2dc1..6afdce2 100644 --- a/db/types.go +++ b/db/types.go @@ -285,10 +285,31 @@ type QueryIncomesRequest struct { After int64 `form:"after"` Before int64 `form:"before"` ActionType []NftEventAction `form:"action_type"` - OrderBy string `form:"order_by"` } type NftIncomeResponse struct { + Address string `json:"address"` + Amount uint64 `json:"amount"` +} + +type QueryIncomesResponse struct { + TotalAmount uint64 `json:"total_amount"` + Incomes []NftIncomeResponse `json:"incomes"` + Pagination PageResponse `json:"pagination"` +} + +type QueryIncomeDetailsRequest struct { + ClassId string `form:"class_id"` + NftId string `form:"nft_id"` + Owner string `form:"owner"` + Address string `form:"address"` + After int64 `form:"after"` + Before int64 `form:"before"` + ActionType []NftEventAction `form:"action_type"` + OrderBy string `form:"order_by"` +} + +type NftIncomeDetailResponse struct { ClassId string `json:"class_id"` NftId string `json:"nft_id"` TxHash string `json:"tx_hash"` @@ -299,9 +320,9 @@ type NftIncomeResponse struct { Amount uint64 `json:"amount"` } -type QueryIncomesResponse struct { - Incomes []NftIncomeResponse `json:"incomes"` - Pagination PageResponse `json:"pagination"` +type QueryIncomeDetailsResponse struct { + IncomeDetails []NftIncomeDetailResponse `json:"income_details"` + Pagination PageResponse `json:"pagination"` } type QueryRankingRequest struct { diff --git a/extractor/marketplace_test.go b/extractor/marketplace_test.go index aa182c3..ae5535a 100644 --- a/extractor/marketplace_test.go +++ b/extractor/marketplace_test.go @@ -337,8 +337,21 @@ func TestListing(t *testing.T) { require.Len(t, eventsRes.Events, 1) require.Equal(t, updatedPrice1, eventsRes.Events[0].Price) - royalties, err := GetNftIncomes(Conn, + incomesRes, err := GetNftIncomes(Conn, QueryIncomesRequest{ + ClassId: nftClasses[0].Id, + }, PageRequest{Limit: 10}, + ) + require.NoError(t, err) + require.Len(t, incomesRes.Incomes, 2) + require.Equal(t, stakeholder1, incomesRes.Incomes[0].Address) + require.Equal(t, royalty1, incomesRes.Incomes[0].Amount) + require.Equal(t, stakeholder2, incomesRes.Incomes[1].Address) + require.Equal(t, royalty2, incomesRes.Incomes[1].Amount) + require.Equal(t, updatedPrice1, incomesRes.TotalAmount) + + incomeDetailsRes, err := GetNftIncomeDetails(Conn, + QueryIncomeDetailsRequest{ ClassId: nftClasses[0].Id, NftId: nfts[0].NftId, OrderBy: "income", @@ -346,14 +359,14 @@ func TestListing(t *testing.T) { }, PageRequest{Limit: 10}, ) require.NoError(t, err) - require.Len(t, royalties.Incomes, 2) - require.Equal(t, nftClasses[0].Id, royalties.Incomes[0].ClassId) - require.Equal(t, nfts[0].NftId, royalties.Incomes[0].NftId) - require.Equal(t, "AAAAAE", royalties.Incomes[0].TxHash) - require.Equal(t, stakeholder1, royalties.Incomes[0].Address) - require.Equal(t, royalty1, royalties.Incomes[0].Amount) - require.Equal(t, stakeholder2, royalties.Incomes[1].Address) - require.Equal(t, royalty2, royalties.Incomes[1].Amount) + require.Len(t, incomeDetailsRes.IncomeDetails, 2) + require.Equal(t, nftClasses[0].Id, incomeDetailsRes.IncomeDetails[0].ClassId) + require.Equal(t, nfts[0].NftId, incomeDetailsRes.IncomeDetails[0].NftId) + require.Equal(t, "AAAAAE", incomeDetailsRes.IncomeDetails[0].TxHash) + require.Equal(t, stakeholder1, incomeDetailsRes.IncomeDetails[0].Address) + require.Equal(t, royalty1, incomeDetailsRes.IncomeDetails[0].Amount) + require.Equal(t, stakeholder2, incomeDetailsRes.IncomeDetails[1].Address) + require.Equal(t, royalty2, incomeDetailsRes.IncomeDetails[1].Amount) } func TestOffer(t *testing.T) { @@ -545,8 +558,21 @@ func TestOffer(t *testing.T) { require.Len(t, eventsRes.Events, 1) require.Equal(t, updatedPrice1, eventsRes.Events[0].Price) - incomes, err := GetNftIncomes(Conn, + incomesRes, err := GetNftIncomes(Conn, QueryIncomesRequest{ + ClassId: nftClasses[0].Id, + }, PageRequest{Limit: 10}, + ) + require.NoError(t, err) + require.Len(t, incomesRes.Incomes, 2) + require.Equal(t, stakeholder1, incomesRes.Incomes[0].Address) + require.Equal(t, royalty1, incomesRes.Incomes[0].Amount) + require.Equal(t, stakeholder2, incomesRes.Incomes[1].Address) + require.Equal(t, royalty2, incomesRes.Incomes[1].Amount) + require.Equal(t, updatedPrice1, incomesRes.TotalAmount) + + incomeDetailsRes, err := GetNftIncomeDetails(Conn, + QueryIncomeDetailsRequest{ ClassId: nftClasses[0].Id, NftId: nfts[0].NftId, OrderBy: "income", @@ -554,12 +580,12 @@ func TestOffer(t *testing.T) { }, PageRequest{Limit: 10}, ) require.NoError(t, err) - require.Len(t, incomes.Incomes, 2) - require.Equal(t, nftClasses[0].Id, incomes.Incomes[0].ClassId) - require.Equal(t, nfts[0].NftId, incomes.Incomes[0].NftId) - require.Equal(t, "AAAAAE", incomes.Incomes[0].TxHash) - require.Equal(t, stakeholder1, incomes.Incomes[0].Address) - require.Equal(t, royalty1, incomes.Incomes[0].Amount) - require.Equal(t, stakeholder2, incomes.Incomes[1].Address) - require.Equal(t, royalty2, incomes.Incomes[1].Amount) + require.Len(t, incomeDetailsRes.IncomeDetails, 2) + require.Equal(t, nftClasses[0].Id, incomeDetailsRes.IncomeDetails[0].ClassId) + require.Equal(t, nfts[0].NftId, incomeDetailsRes.IncomeDetails[0].NftId) + require.Equal(t, "AAAAAE", incomeDetailsRes.IncomeDetails[0].TxHash) + require.Equal(t, stakeholder1, incomeDetailsRes.IncomeDetails[0].Address) + require.Equal(t, royalty1, incomeDetailsRes.IncomeDetails[0].Amount) + require.Equal(t, stakeholder2, incomeDetailsRes.IncomeDetails[1].Address) + require.Equal(t, royalty2, incomeDetailsRes.IncomeDetails[1].Amount) } diff --git a/extractor/nft_test.go b/extractor/nft_test.go index 814b00a..5afee6a 100644 --- a/extractor/nft_test.go +++ b/extractor/nft_test.go @@ -215,8 +215,8 @@ func TestSendNftWithPrice(t *testing.T) { receiver := ADDR_01_LIKE stakeholder1 := ADDR_02_LIKE stakeholder2 := ADDR_03_LIKE - price := 100 - royalty1 := 78 + price := uint64(100) + royalty1 := uint64(78) royalty2 := price - royalty1 txs := []string{ fmt.Sprintf(` @@ -252,30 +252,43 @@ func TestSendNftWithPrice(t *testing.T) { require.Equal(t, ADDR_02_LIKE, eventRes.Events[0].Receiver) require.Equal(t, "AAAAAA", eventRes.Events[0].TxHash) require.Equal(t, ACTION_SEND, eventRes.Events[0].Action) - require.Equal(t, uint64(price), eventRes.Events[0].Price) + require.Equal(t, price, eventRes.Events[0].Price) - incomeRes, err := GetNftIncomes(Conn, QueryIncomesRequest{ + incomesRes, err := GetNftIncomes(Conn, + QueryIncomesRequest{ + ClassId: nftClasses[0].Id, + }, PageRequest{Limit: 10, Reverse: true}, + ) + require.NoError(t, err) + require.Len(t, incomesRes.Incomes, 2) + require.Equal(t, stakeholder1, incomesRes.Incomes[0].Address) + require.Equal(t, royalty1, incomesRes.Incomes[0].Amount) + require.Equal(t, stakeholder2, incomesRes.Incomes[1].Address) + require.Equal(t, royalty2, incomesRes.Incomes[1].Amount) + require.Equal(t, price, incomesRes.TotalAmount) + + incomeDetailsRes, err := GetNftIncomeDetails(Conn, QueryIncomeDetailsRequest{ ClassId: nftClasses[0].Id, - OrderBy: "royalty", + OrderBy: "income", ActionType: []NftEventAction{ACTION_SEND}, }, PageRequest{Limit: 10, Reverse: true}) require.NoError(t, err) - require.Len(t, incomeRes.Incomes, 2) - require.Equal(t, nfts[0].ClassId, incomeRes.Incomes[0].ClassId) - require.Equal(t, nfts[0].NftId, incomeRes.Incomes[0].NftId) - require.Equal(t, ADDR_02_LIKE, incomeRes.Incomes[0].Owner) - require.Equal(t, stakeholder1, incomeRes.Incomes[0].Address) - require.Equal(t, uint64(royalty1), incomeRes.Incomes[0].Amount) - require.Equal(t, stakeholder2, incomeRes.Incomes[1].Address) - require.Equal(t, uint64(royalty2), incomeRes.Incomes[1].Amount) - require.Equal(t, uint64(price), incomeRes.Incomes[0].Amount+incomeRes.Incomes[1].Amount) + require.Len(t, incomeDetailsRes.IncomeDetails, 2) + require.Equal(t, nfts[0].ClassId, incomeDetailsRes.IncomeDetails[0].ClassId) + require.Equal(t, nfts[0].NftId, incomeDetailsRes.IncomeDetails[0].NftId) + require.Equal(t, ADDR_02_LIKE, incomeDetailsRes.IncomeDetails[0].Owner) + require.Equal(t, stakeholder1, incomeDetailsRes.IncomeDetails[0].Address) + require.Equal(t, royalty1, incomeDetailsRes.IncomeDetails[0].Amount) + require.Equal(t, stakeholder2, incomeDetailsRes.IncomeDetails[1].Address) + require.Equal(t, royalty2, incomeDetailsRes.IncomeDetails[1].Amount) + require.Equal(t, price, incomeDetailsRes.IncomeDetails[0].Amount+incomeDetailsRes.IncomeDetails[1].Amount) row := Conn.QueryRow(context.Background(), `SELECT latest_price, price_updated_at FROM nft WHERE class_id = $1 AND nft_id = $2`, nftClasses[0].Id, nfts[0].NftId) var lastPrice uint64 var priceUpdatedAt time.Time err = row.Scan(&lastPrice, &priceUpdatedAt) require.NoError(t, err) - require.Equal(t, uint64(price), lastPrice) + require.Equal(t, price, lastPrice) require.Equal(t, timestamp.UTC(), priceUpdatedAt.UTC()) } diff --git a/rest/nft.go b/rest/nft.go index bb7fb6e..b1d2aa8 100644 --- a/rest/nft.go +++ b/rest/nft.go @@ -218,6 +218,44 @@ func handleNftIncome(c *gin.Context) { } } + p, err := getPagination(c) + if err != nil { + c.AbortWithStatusJSON(400, gin.H{"error": err}) + return + } + + conn := getConn(c) + + res, err := db.GetNftIncomes(conn, form, p) + if err != nil { + c.AbortWithStatusJSON(500, gin.H{"error": err.Error()}) + return + } + + c.JSON(200, res) +} + +func handleNftIncomeDetail(c *gin.Context) { + var form db.QueryIncomeDetailsRequest + if err := c.ShouldBindQuery(&form); err != nil { + c.AbortWithStatusJSON(400, gin.H{"error": "invalid inputs: " + err.Error()}) + return + } + + if form.ClassId == "" && form.NftId == "" && form.Owner == "" && form.Address == "" { + c.AbortWithStatusJSON(400, gin.H{"error": "must provide either class_id, nft_id, owner or address"}) + return + } + + if len(form.ActionType) != 0 { + for _, t := range form.ActionType { + if t != db.ACTION_SEND && t != db.ACTION_BUY && t != db.ACTION_SELL { + c.AbortWithStatusJSON(400, gin.H{"error": "action_type should only include /cosmos.nft.v1beta1.MsgSend, buy_nft or sell_nft"}) + return + } + } + } + if form.OrderBy != "" && form.OrderBy != "price" && form.OrderBy != "income" && form.OrderBy != "default" { c.AbortWithStatusJSON(400, gin.H{"error": "order_by should either be price, income or default"}) return @@ -231,7 +269,7 @@ func handleNftIncome(c *gin.Context) { conn := getConn(c) - res, err := db.GetNftIncomes(conn, form, p) + res, err := db.GetNftIncomeDetails(conn, form, p) if err != nil { c.AbortWithStatusJSON(500, gin.H{"error": err.Error()}) return diff --git a/rest/router.go b/rest/router.go index 2bba991..e141145 100644 --- a/rest/router.go +++ b/rest/router.go @@ -45,6 +45,7 @@ func GetRouter(pool *pgxpool.Pool, defaultApiAddresses []string) *gin.Engine { nft.GET("/collector", handleNftCollectors) nft.GET("/creator", handleNftCreators) nft.GET("/income", handleNftIncome) + nft.GET("/income/detail", handleNftIncomeDetail) nft.GET("/user-stat", handleNftUserStat) nft.GET("/marketplace", handleNftMarketplaceItem) nft.GET("/collector-top-ranked-creators", handleNftCollectorTopRankedCreatorsRequest)