From 5410033aa990a43113d195fa03082c11eb7937a7 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Thu, 25 Nov 2021 11:04:16 +0400 Subject: [PATCH] initial commit --- .idea/.gitignore | 8 + .idea/inspectionProfiles/Project_Default.xml | 10 + .idea/modules.xml | 8 + .idea/pancakeswapnft.iml | 9 + .idea/vcs.xml | 6 + collection.go | 7 + floor.go | 118 +++++++ go.mod | 12 + go.sum | 36 +++ nft.go | 306 +++++++++++++++++++ squad.go | 202 ++++++++++++ transaction.go | 199 ++++++++++++ user.go | 38 +++ userservice.go | 100 ++++++ 14 files changed, 1059 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/pancakeswapnft.iml create mode 100644 .idea/vcs.xml create mode 100644 collection.go create mode 100644 floor.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 nft.go create mode 100644 squad.go create mode 100644 transaction.go create mode 100644 user.go create mode 100644 userservice.go diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..146ab09 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..f24df2e --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/pancakeswapnft.iml b/.idea/pancakeswapnft.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/pancakeswapnft.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/collection.go b/collection.go new file mode 100644 index 0000000..a4bd325 --- /dev/null +++ b/collection.go @@ -0,0 +1,7 @@ +package pancakeswapnft + +type Collection struct { + Id string `json:"id"` + FirstTokenId int + TokenCount int +} diff --git a/floor.go b/floor.go new file mode 100644 index 0000000..287d994 --- /dev/null +++ b/floor.go @@ -0,0 +1,118 @@ +package pancakeswapnft + +import ( + "context" + "sync" + "time" + + "github.com/machinebox/graphql" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +type FloorService struct { + Done chan struct{} + + collection string + graphClient *graphql.Client + + floors map[time.Time]float64 + lock *sync.RWMutex + cacheDuration time.Duration +} + +func NewFloorService(ctx context.Context, collection string, graphClient *graphql.Client, cacheDuration time.Duration) *FloorService { + s := &FloorService{ + Done: make(chan struct{}), + collection: collection, + graphClient: graphClient, + floors: make(map[time.Time]float64), + lock: &sync.RWMutex{}, + cacheDuration: cacheDuration, + } + s.start(ctx) + return s +} + +func (s *FloorService) start(ctx context.Context) { + t := time.NewTicker(1 * time.Minute) + err := s.watchfloor(ctx) + if err != nil { + log.Error().Err(err).Msg("watching floor") + } + go func() { + defer close(s.Done) + for { + select { + case <-ctx.Done(): + return + case <-t.C: + err := s.watchfloor(ctx) + if err != nil { + log.Error().Err(err).Msg("watching floor") + } + } + } + }() +} + +func (s *FloorService) watchfloor(ctx context.Context) error { + req := graphql.NewRequest(` + query ($first: Int, $skip: Int!, $where: NFT_filter, $orderBy: NFT_orderBy, $orderDirection: OrderDirection) { + nfts(where: $where, first: $first, orderBy: $orderBy, orderDirection: $orderDirection, skip: $skip) { + currentAskPrice + } + }`) + req.Var("first", 1) + req.Var("orderBy", "currentAskPrice") + req.Var("orderDirection", "asc") + req.Var("skip", 0) + + req.Var("where", struct { + Collection string `json:"collection"` + IsTradable bool `json:"isTradable"` + }{ + Collection: s.collection, + IsTradable: true, + }) + + var respData struct { + Nfts []Nft `json:"nfts"` + } + if err := s.graphClient.Run(ctx, req, &respData); err != nil { + return errors.Wrap(err, "querying graphql") + } + + if len(respData.Nfts) <= 0 { + return errors.Errorf("no tradable nft found") + } + floor := respData.Nfts[0].CurrentAskPrice + + s.lock.Lock() + newfloors := make(map[time.Time]float64) + for t, f := range s.floors { + if t.After(time.Now().Add(-s.cacheDuration)) { + newfloors[t] = f + } + + } + newfloors[time.Now()] = floor + s.floors = newfloors + s.lock.Unlock() + return nil +} + +func (s *FloorService) GetFloor() float64 { + s.lock.RLock() + defer s.lock.RUnlock() + + var tot float64 + for _, f := range s.floors { + tot += f + } + res := tot / float64(len(s.floors)) + if res < 0.001 { + res = 3 + } + return res +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26ce927 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module git.lehouerou.net/laurent/pancakeswapnft + +go 1.17 + +require ( + github.com/dustin/go-humanize v1.0.0 + github.com/machinebox/graphql v0.2.2 + github.com/pkg/errors v0.9.1 + github.com/rs/zerolog v1.26.0 +) + +require github.com/matryer/is v1.4.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7f67844 --- /dev/null +++ b/go.sum @@ -0,0 +1,36 @@ +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/machinebox/graphql v0.2.2 h1:dWKpJligYKhYKO5A2gvNhkJdQMNZeChZYyBbrZkBZfo= +github.com/machinebox/graphql v0.2.2/go.mod h1:F+kbVMHuwrQ5tYgU9JXlnskM8nOaFxCAEolaQybkjWA= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.26.0 h1:ORM4ibhEZeTeQlCojCK2kPz1ogAY4bGs4tD+SaAdGaE= +github.com/rs/zerolog v1.26.0/go.mod h1:yBiM87lvSqX8h0Ww4sdzNSkVYZ8dL2xjZJG1lAuGZEo= +github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/nft.go b/nft.go new file mode 100644 index 0000000..1274d39 --- /dev/null +++ b/nft.go @@ -0,0 +1,306 @@ +package pancakeswapnft + +import ( + "context" + "fmt" + "sort" + "strconv" + "sync" + "time" + + "github.com/dustin/go-humanize" + "github.com/machinebox/graphql" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +type AskOrder struct { + Id string `json:"id"` + Block string `json:"block"` + AskPrice float64 `json:"askPrice,string"` + Timestamp int64 `json:"timestamp,string"` + Seller User `json:"seller"` + Nft Nft `json:"nft"` + Collection Collection `json:"collection"` + OrderType string `json:"orderType"` +} + +type Nft struct { + Id string `json:"id"` + TokenID int64 `json:"tokenId,string"` + OtherId int64 `json:"otherId,string"` + Collection Collection `json:"collection"` + MetadataUrl string `json:"metadataUrl"` + UpdatedAt int64 `json:"updatedAt,string"` + CurrentAskPrice float64 `json:"currentAskPrice,string"` + CurrentSeller string `json:"currentSeller"` + LatestTradedPriceInBNB float64 `json:"latestTradedPriceInBNB,string"` + TradeVolumeBNB float64 `json:"tradeVolumeBNB,string"` + TotalTrades int64 `json:"totalTrades,string"` + TransactionHistory []Transaction `json:"transactionHistory"` + AskHistory []AskOrder `json:"askHistory"` + IsTradable bool `json:"IsTradable"` + Squad Squad + Score float64 + Url string + TransactionCount int + UpdatedTimeString string + IsRecent bool + IsVeryRecent bool + IsNotMine bool +} + +type MarketService struct { + collection string + graphClient *graphql.Client + squadservice *SquadService + floorService *FloorService + + nfts []Nft + floor float64 + nftslock *sync.RWMutex +} + +func NewMarketService(collection string, graphClient *graphql.Client, squadservice *SquadService, floorService *FloorService) *MarketService { + return &MarketService{ + collection: collection, + graphClient: graphClient, + squadservice: squadservice, + floorService: floorService, + + nfts: nil, + floor: 0, + nftslock: &sync.RWMutex{}, + } +} + +func (s *MarketService) Start(ctx context.Context) chan struct{} { + done := make(chan struct{}) + t := time.NewTicker(5 * time.Second) + go func() { + defer close(done) + + err := s.updateMarket(ctx) + if err != nil { + log.Error().Err(err).Msg("updating market") + } + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + err := s.updateMarket(ctx) + if err != nil { + log.Error().Err(err).Msg("updating market") + } + } + } + }() + return done +} + +func (s *MarketService) updateMarket(ctx context.Context) error { + nfts, floor, err := s.getFirstNPages(ctx, 5) + if err != nil { + return errors.Wrap(err, "get first pages") + } + s.setNftCache(nfts, floor) + return nil +} + +func (s *MarketService) GetNftCache() ([]Nft, float64) { + s.nftslock.RLock() + defer s.nftslock.RUnlock() + return s.nfts, s.floor +} + +func (s *MarketService) setNftCache(nfts []Nft, floor float64) { + s.nftslock.Lock() + defer s.nftslock.Unlock() + s.nfts = nfts + s.floor = floor +} + +func (s *MarketService) GetNft(ctx context.Context, collection string, tokenId int) (Nft, error) { + req := graphql.NewRequest(` + query ($where: NFT_filter) { + nfts(where: $where) { + id + tokenId + otherId + collection { + id + } + metadataUrl + currentAskPrice + currentSeller + updatedAt + latestTradedPriceInBNB + tradeVolumeBNB + totalTrades + transactionHistory { + id + block + timestamp + askPrice + netPrice + buyer { + id + } + seller { + id + } + withBNB + } + askHistory { + id + block + askPrice + timestamp + seller { + id + } + orderType + } + } + }`) + req.Var("where", struct { + Collection string `json:"collection"` + TokenId string `json:"tokenId"` + }{ + Collection: collection, + TokenId: strconv.Itoa(tokenId), + }) + + var respData struct { + Nfts []Nft `json:"nfts"` + } + if err := s.graphClient.Run(ctx, req, &respData); err != nil { + return Nft{}, errors.Wrap(err, "querying graphql") + } + + if len(respData.Nfts) <= 0 { + return Nft{}, errors.Errorf("no nft found for id %d", tokenId) + } + res := respData.Nfts[0] + sq, err := s.squadservice.GetSquad(res.TokenID) + if err != nil { + return Nft{}, errors.Wrap(err, "getting squad") + } + res.Squad = sq + res.Score = 10 / ((float64(sq.Rank) / float64(10000)) * res.CurrentAskPrice) + res.Url = fmt.Sprintf("https://pancakeswap.finance/nfts/collections/%s/%d", collection, res.TokenID) + res.TransactionCount = len(res.TransactionHistory) + sort.Slice(res.TransactionHistory, func(i, j int) bool { + return res.TransactionHistory[i].Timestamp > res.TransactionHistory[j].Timestamp + }) + + res.UpdatedTimeString = humanize.Time(time.Unix(res.UpdatedAt, 0)) + res.IsRecent = time.Unix(res.UpdatedAt, 0).After(time.Now().Add(-1 * time.Hour)) + res.IsVeryRecent = time.Unix(res.UpdatedAt, 0).After(time.Now().Add(-5 * time.Minute)) + res.IsNotMine = res.CurrentSeller != "0x73d74055cc40ee8c62978a37a3b938f6e62739ed" + + return res, nil +} + +func (s *MarketService) getPage(ctx context.Context, collection string, pagenum int) ([]Nft, float64, error) { + req := graphql.NewRequest(` + query ($first: Int, $skip: Int!, $where: NFT_filter, $orderBy: NFT_orderBy, $orderDirection: OrderDirection) { + nfts(where: $where, first: $first, orderBy: $orderBy, orderDirection: $orderDirection, skip: $skip) { + tokenId + currentAskPrice + currentSeller + updatedAt + transactionHistory { + timestamp + askPrice + } + } + }`) + + req.Var("first", 1000) + req.Var("orderBy", "currentAskPrice") + req.Var("orderDirection", "asc") + req.Var("skip", (pagenum-1)*1000) + + req.Var("where", struct { + Collection string `json:"collection"` + IsTradable bool `json:"isTradable"` + }{ + Collection: collection, + IsTradable: true, + }) + + var respData struct { + Nfts []Nft `json:"nfts"` + } + if err := s.graphClient.Run(ctx, req, &respData); err != nil { + return nil, 0, errors.Wrap(err, "querying graphql") + } + var res []Nft + floor := float64(0) + for _, nft := range respData.Nfts { + squad, err := s.squadservice.GetSquad(nft.TokenID) + if err != nil { + log.Error().Err(err).Msgf("getting squad for tokenid %d", nft.TokenID) + continue + } + nft.Squad = squad + nft.Score = nft.Squad.GetScoreByPrice(s.floorService, nft.CurrentAskPrice) + nft.Url = fmt.Sprintf("https://pancakeswap.finance/nfts/collections/%s/%d", collection, nft.TokenID) + nft.TransactionCount = len(nft.TransactionHistory) + sort.Slice(nft.TransactionHistory, func(i, j int) bool { + return nft.TransactionHistory[i].Timestamp > nft.TransactionHistory[j].Timestamp + }) + + nft.UpdatedTimeString = humanize.Time(time.Unix(nft.UpdatedAt, 0)) + nft.IsRecent = time.Unix(nft.UpdatedAt, 0).After(time.Now().Add(-1 * time.Hour)) + nft.IsVeryRecent = time.Unix(nft.UpdatedAt, 0).After(time.Now().Add(-5 * time.Minute)) + if floor == 0 || nft.CurrentAskPrice < floor { + floor = nft.CurrentAskPrice + } + nft.IsNotMine = nft.CurrentSeller != "0x73d74055cc40ee8c62978a37a3b938f6e62739ed" + res = append(res, nft) + } + + return res, floor, nil +} + +func (s *MarketService) getFirstNPages(ctx context.Context, n int) ([]Nft, float64, error) { + var res []Nft + var floor float64 + for i := 1; i <= n; i++ { + page, tmpfloor, err := s.getPage(ctx, s.collection, i) + if err != nil { + return nil, 0, errors.Wrapf(err, "getting page %d", i) + } + res = append(res, page...) + if floor == 0 || tmpfloor < floor { + floor = tmpfloor + } + } + return res, floor, nil +} + +func (s *MarketService) getAll(ctx context.Context) ([]Nft, float64, error) { + var res []Nft + var floor float64 + currentpage := 1 + for { + page, tmpfloor, err := s.getPage(ctx, s.collection, currentpage) + if err != nil { + return nil, 0, errors.Wrapf(err, "getting page %d", currentpage) + } + if len(page) == 0 { + break + } + res = append(res, page...) + if floor == 0 || tmpfloor < floor { + floor = tmpfloor + } + + currentpage++ + } + return res, floor, nil +} diff --git a/squad.go b/squad.go new file mode 100644 index 0000000..cd9f7f4 --- /dev/null +++ b/squad.go @@ -0,0 +1,202 @@ +package pancakeswapnft + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "sort" + "strconv" + "sync" + "time" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +type Squad struct { + TokenId int64 `json:"tokenId,string"` + Name string `json:"name"` + Description string `json:"description"` + Image struct { + Original string `json:"original"` + Thumbnail string `json:"thumbnail"` + Mp4 string `json:"mp4"` + Webm string `json:"webm"` + Gif string `json:"gif"` + } `json:"image"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Attributes []struct { + TraitType string `json:"traitType"` + Value string `json:"value"` + DisplayType string `json:"displayType"` + } `json:"attributes"` + Collection struct { + Name string `json:"name"` + } `json:"collection"` + RarityScore float64 `json:"rarity_score"` + Rank int `json:"rank"` + TraitCount int `json:"trait_count"` +} + +type SquadService struct { + Collection Collection + squads map[int64]Squad + squadslock *sync.RWMutex +} + +func NewSquadService(collection Collection) *SquadService { + return &SquadService{ + Collection: collection, + squads: make(map[int64]Squad), + squadslock: &sync.RWMutex{}, + } +} + +func (s *SquadService) LoadCacheFromFile(filename string) error { + if _, err := os.Stat(filename); os.IsNotExist(err) { + err := s.LoadCacheFromWeb(s.Collection.FirstTokenId, s.Collection.TokenCount) + if err != nil { + return errors.Wrap(err, "loading cache from web") + } + err = s.SaveCacheToFile(filename) + if err != nil { + return errors.Wrap(err, "saving cache to file") + } + return nil + } + f, err := os.OpenFile(filename, os.O_RDONLY, os.ModePerm) + if err != nil { + return errors.Wrapf(err, "opening file %s", filename) + } + var res map[int64]Squad + err = json.NewDecoder(f).Decode(&res) + if err != nil { + return errors.Wrap(err, "decoding json") + } + s.squadslock.Lock() + s.squads = res + defer s.squadslock.Unlock() + return nil +} + +func (s *SquadService) SaveCacheToFile(filepath string) error { + file, _ := os.OpenFile(filepath, os.O_CREATE, os.ModePerm) + defer file.Close() + s.squadslock.RLock() + defer s.squadslock.RUnlock() + err := json.NewEncoder(file).Encode(s.squads) + if err != nil { + return errors.Wrapf(err, "encoding to json & writing to file %s", filepath) + } + return nil +} + +func (s *SquadService) getSquadFromWeb(id int) (Squad, error) { + resp, err := http.Get(fmt.Sprintf("https://nft.pancakeswap.com/api/v1/collections/%s/tokens/%d", s.Collection.Id, id)) + if err != nil { + return Squad{}, err + } + defer resp.Body.Close() + + var res struct { + Data Squad `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&res) + if err != nil { + return Squad{}, err + } + return res.Data, nil +} + +func (s *SquadService) getRarityData() (map[string]map[string]int, error) { + resp, err := http.Get(fmt.Sprintf("https://nft.pancakeswap.com/api/v1/collections/%s/distribution", s.Collection.Id)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var res struct { + Data map[string]map[string]int `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&res) + if err != nil { + return nil, err + } + return res.Data, nil +} + +func (s *SquadService) LoadCacheFromWeb(starting int, count int) error { + squadcount := 10000 + var squads []*Squad + for i := starting; i <= count+starting-1; i++ { + log.Printf("getting %d", i) + s, err := s.getSquadFromWeb(i) + if err != nil { + return errors.Wrapf(err, "getting squad %d", i) + } + squads = append(squads, &s) + } + + raritydata, err := s.getRarityData() + if err != nil { + return errors.Wrap(err, "getting rarity data from api") + } + + raritydata["traitcount"] = make(map[string]int) + for _, squad := range squads { + traitcount := 0 + for _, attribute := range squad.Attributes { + if attribute.Value != "None" { + traitcount++ + } + } + raritydata["traitcount"][strconv.Itoa(traitcount)]++ + squad.TraitCount = traitcount + if traitcount == 0 { + squadcount-- + } + } + + for _, squad := range squads { + rarityScore := float64(0) + for _, attribute := range squad.Attributes { + rarityScore += 1 / (float64(raritydata[attribute.TraitType][attribute.Value]) / float64(squadcount)) + } + rarityScore += 1 / (float64(raritydata["traitcount"][strconv.Itoa(squad.TraitCount)]) / float64(squadcount)) + squad.RarityScore = rarityScore + } + + sort.Slice(squads, func(i, j int) bool { + return squads[i].RarityScore > squads[j].RarityScore + }) + + for i, squad := range squads { + squad.Rank = i + 1 + } + s.squadslock.Lock() + defer s.squadslock.Unlock() + for _, squad := range squads { + s.squads[squad.TokenId] = *squad + } + return nil +} + +func (s *SquadService) GetSquad(id int64) (Squad, error) { + s.squadslock.RLock() + defer s.squadslock.RUnlock() + if s, ok := s.squads[id]; !ok { + return Squad{}, errors.Errorf("squad %d not found", id) + } else { + return s, nil + } +} + +func (s *Squad) GetScoreByPrice(floorService *FloorService, price float64) float64 { + return 1 + (4 / ((price - (floorService.GetFloor() - 1.5)) * float64(s.Rank) / float64(10000))) +} + +func (s *Squad) GetPriceByScore(floorService *FloorService, score float64) float64 { + return (floorService.GetFloor() - 1.5) + (4 / ((score - 1) * float64(s.Rank) / float64(10000))) +} diff --git a/transaction.go b/transaction.go new file mode 100644 index 0000000..a060b5a --- /dev/null +++ b/transaction.go @@ -0,0 +1,199 @@ +package pancakeswapnft + +import ( + "context" + "fmt" + "github.com/dustin/go-humanize" + "sort" + "strconv" + "time" + + "github.com/machinebox/graphql" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +type Transaction struct { + Id string `json:"id"` + Block string `json:"block"` + Timestamp int64 `json:"timestamp,string"` + Collection Collection `json:"collection"` + Nft Nft `json:"nft"` + AskPrice float64 `json:"askPrice,string"` + NetPrice float64 `json:"netPrice,string"` + Buyer User `json:"buyer"` + Seller User `json:"seller"` + WithBNB bool `json:"withBNB"` + Time time.Time + Squad Squad + Score float64 + Url string + TimeDescription string +} + +type TransactionService struct { + graphClient *graphql.Client + squadservice *SquadService + floorService *FloorService +} + +func NewTransactionService(graphClient *graphql.Client, squadservice *SquadService, floorService *FloorService) *TransactionService { + return &TransactionService{ + graphClient: graphClient, + squadservice: squadservice, + floorService: floorService, + } +} + +type WhereOptions struct { + Collection string `json:"collection,omitempty"` + TimestampLte string `json:"timestamp_lte,omitempty"` +} + +func (s *TransactionService) GetLightPageByLimit(ctx context.Context, pagenumber int, limit int, opts WhereOptions) ([]Transaction, error) { + req := graphql.NewRequest(` + query getNftsMarketData($first: Int, $skip: Int!, $where: Transaction_filter, $orderBy: Transaction_orderBy, $orderDirection: OrderDirection) { + transactions(where: $where, first: $first, orderBy: $orderBy, orderDirection: $orderDirection, skip: $skip) { + timestamp + askPrice + nft { + tokenId + } + buyer { + id + } + seller { + id + } + } + }`) + req.Var("first", limit) + req.Var("orderBy", "timestamp") + req.Var("orderDirection", "desc") + req.Var("skip", (pagenumber-1)*1000) + req.Var("where", opts) + + var respData struct { + Transactions []*Transaction `json:"transactions"` + } + if err := s.graphClient.Run(ctx, req, &respData); err != nil { + return nil, errors.Wrap(err, "requesting graphql") + } + + for _, transaction := range respData.Transactions { + squad, err := s.squadservice.GetSquad(transaction.Nft.TokenID) + if err != nil { + log.Error().Err(err).Msgf("getting squad for tokenid %d", transaction.Nft.TokenID) + continue + } + transaction.Squad = squad + + transaction.Score = transaction.Squad.GetScoreByPrice(s.floorService, transaction.AskPrice) + transaction.Url = fmt.Sprintf("https://pancakeswap.finance/nfts/collections/%s/%d", opts.Collection, transaction.Nft.TokenID) + + transaction.Time = time.Unix(transaction.Timestamp, 0) + transaction.TimeDescription = humanize.Time(transaction.Time) + } + + var res []Transaction + for _, transaction := range respData.Transactions { + res = append(res, *transaction) + } + return res, nil +} + +func (s *TransactionService) GetPageByLimit(ctx context.Context, pagenumber int, limit int, opts WhereOptions) ([]Transaction, error) { + req := graphql.NewRequest(` + query getNftsMarketData($first: Int, $skip: Int!, $where: Transaction_filter, $orderBy: Transaction_orderBy, $orderDirection: OrderDirection) { + transactions(where: $where, first: $first, orderBy: $orderBy, orderDirection: $orderDirection, skip: $skip) { + id + block + timestamp + askPrice + netPrice + nft { + tokenId + } + collection { + id + } + buyer { + id + } + seller { + id + } + withBNB + } + }`) + req.Var("first", limit) + req.Var("orderBy", "timestamp") + req.Var("orderDirection", "desc") + req.Var("skip", (pagenumber-1)*1000) + req.Var("where", opts) + + var respData struct { + Transactions []*Transaction `json:"transactions"` + } + if err := s.graphClient.Run(ctx, req, &respData); err != nil { + return nil, errors.Wrap(err, "requesting graphql") + } + + for _, transaction := range respData.Transactions { + s, err := s.squadservice.GetSquad(transaction.Nft.TokenID) + if err != nil { + log.Error().Err(err).Msgf("getting squad for tokenid %d", transaction.Nft.TokenID) + continue + } + transaction.Squad = s + + transaction.Score = 10 / ((float64(s.Rank) / float64(10000)) * transaction.AskPrice) + transaction.Url = fmt.Sprintf("https://pancakeswap.finance/nfts/collections/%s/%d", opts.Collection, transaction.Nft.TokenID) + + transaction.Time = time.Unix(transaction.Timestamp, 0) + transaction.TimeDescription = humanize.Time(transaction.Time) + } + + var res []Transaction + for _, transaction := range respData.Transactions { + res = append(res, *transaction) + } + return res, nil +} +func (s *TransactionService) GetAll(ctx context.Context, collection string) ([]Transaction, error) { + var res []Transaction + currentpage := 1 + whereoptions := WhereOptions{ + Collection: collection, + TimestampLte: "", + } + now := time.Now() + tmpmap := make(map[string]Transaction) +loop: + for { + log.Debug().Msgf("timestamp : %s", whereoptions.TimestampLte) + page, err := s.GetPageByLimit(ctx, currentpage, 1000, whereoptions) + if err != nil { + return nil, errors.Wrapf(err, "getting page %d", currentpage) + } + if len(page) == 0 { + break + } + for _, transaction := range page { + if transaction.Time.Before(now.Add(-7 * 24 * time.Hour)) { + break loop + } + tmpmap[transaction.Id] = transaction + } + + currentpage++ + whereoptions.TimestampLte = strconv.Itoa(int(page[len(page)-1].Timestamp)) + } + for _, transaction := range tmpmap { + res = append(res, transaction) + } + sort.Slice(res, func(i, j int) bool { + return res[i].Timestamp > res[j].Timestamp + }) + return res, nil +} diff --git a/user.go b/user.go new file mode 100644 index 0000000..6261ecd --- /dev/null +++ b/user.go @@ -0,0 +1,38 @@ +package pancakeswapnft + +import ( + "time" +) + +type User struct { + Address string `json:"id"` + NumberTokensListed int64 `json:"numberTokensListed,string"` + NumberTokensPurchased int64 `json:"numberTokensPurchased,string"` + NumberTokensSold int64 `json:"numberTokensSold,string"` + TotalVolumeInBNBTokensPurchased float64 `json:"totalVolumeInBNBTokensPurchased,string"` + TotalVolumeInBNBTokensSold float64 `json:"totalVolumeInBNBTokensSold,string"` + TotalFeesCollectedInBNB float64 `json:"totalFeesCollectedInBNB,string"` + BuyTradeHistory []Transaction `json:"buyTradeHistory"` + SellTradeHistory []Transaction `json:"sellTradeHistory"` + AskOrderHistory []AskOrder `json:"askOrderHistory"` + AverageTokenPriceInBNBPurchased float64 `json:"averageTokenPriceInBNBPurchased,string"` + AverageTokenPriceInBNBSold float64 `json:"averageTokenPriceInBNBSold,string"` +} + +func (u *User) GetPnl() float64 { + var res float64 + for _, transaction := range u.SellTradeHistory { + res += transaction.NetPrice + } + + for _, buy := range u.BuyTradeHistory { + buyTime := time.Unix(buy.Timestamp, 0) + for _, sell := range u.SellTradeHistory { + sellTime := time.Unix(sell.Timestamp, 0) + if sell.Nft.TokenID == buy.Nft.TokenID && sellTime.After(buyTime) { + res -= buy.AskPrice + } + } + } + return res +} diff --git a/userservice.go b/userservice.go new file mode 100644 index 0000000..10d77a8 --- /dev/null +++ b/userservice.go @@ -0,0 +1,100 @@ +package pancakeswapnft + +import ( + "context" + "github.com/machinebox/graphql" + "github.com/pkg/errors" + "strings" +) + +const ( + userQuery = ` + query ($where: User_filter) { + users(where: $where) { + id + numberTokensListed + numberTokensPurchased + numberTokensSold + totalVolumeInBNBTokensPurchased + totalVolumeInBNBTokensSold + totalFeesCollectedInBNB + buyTradeHistory { + id + block + timestamp + askPrice + netPrice + buyer { + id + } + seller { + id + } + nft { + tokenId + } + withBNB + } + sellTradeHistory { + id + block + timestamp + askPrice + netPrice + buyer { + id + } + seller { + id + } + nft { + tokenId + } + withBNB + } + askOrderHistory { + id + block + askPrice + timestamp + seller { + id + } + orderType + } + averageTokenPriceInBNBPurchased + averageTokenPriceInBNBSold + } + }` +) + +type UserService struct { + graphClient *graphql.Client +} + +func NewUserService(graphclient *graphql.Client) *UserService { + return &UserService{ + graphClient: graphclient, + } +} + +func (s *UserService) GetUser(ctx context.Context, address string) (User, error) { + req := graphql.NewRequest(userQuery) + req.Var("where", struct { + Id string `json:"id"` + }{ + Id: strings.ToLower(address), + }) + + var respData struct { + Users []User `json:"users"` + } + if err := s.graphClient.Run(ctx, req, &respData); err != nil { + return User{}, errors.Wrap(err, "requesting graphql") + } + + if len(respData.Users) == 0 { + return User{}, errors.Errorf("no user found with id %s", address) + } + return respData.Users[0], nil +}