commit 5410033aa990a43113d195fa03082c11eb7937a7 Author: Laurent Le Houerou Date: Thu Nov 25 11:04:16 2021 +0400 initial commit 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 +}