feat: Waku v2 bridge

Issue #12610
This commit is contained in:
Michal Iskierko
2023-11-12 13:29:38 +01:00
parent 56e7bd01ca
commit 6d31343205
6716 changed files with 1982502 additions and 5891 deletions

View File

@@ -0,0 +1,318 @@
package opensea
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
walletCommon "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/connection"
"github.com/status-im/status-go/services/wallet/thirdparty"
)
const assetLimitV2 = 50
func getV2BaseURL(chainID walletCommon.ChainID) (string, error) {
switch uint64(chainID) {
case walletCommon.EthereumMainnet, walletCommon.ArbitrumMainnet, walletCommon.OptimismMainnet:
return "https://api.opensea.io/v2", nil
case walletCommon.EthereumSepolia, walletCommon.ArbitrumSepolia, walletCommon.OptimismSepolia:
return "https://testnets-api.opensea.io/v2", nil
}
return "", thirdparty.ErrChainIDNotSupported
}
func (o *ClientV2) ID() string {
return OpenseaV2ID
}
func (o *ClientV2) IsChainSupported(chainID walletCommon.ChainID) bool {
_, err := getV2BaseURL(chainID)
return err == nil
}
func (o *ClientV2) IsConnected() bool {
return o.connectionStatus.IsConnected()
}
func getV2URL(chainID walletCommon.ChainID, path string) (string, error) {
baseURL, err := getV2BaseURL(chainID)
if err != nil {
return "", err
}
return fmt.Sprintf("%s/%s", baseURL, path), nil
}
type ClientV2 struct {
client *HTTPClient
apiKey string
connectionStatus *connection.Status
urlGetter urlGetter
}
// new opensea v2 client.
func NewClientV2(apiKey string, httpClient *HTTPClient) *ClientV2 {
if apiKey == "" {
log.Warn("OpenseaV2 API key not available")
}
return &ClientV2{
client: httpClient,
apiKey: apiKey,
connectionStatus: connection.NewStatus(),
urlGetter: getV2URL,
}
}
func (o *ClientV2) FetchAllAssetsByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
// No dedicated endpoint to filter owned assets by contract address.
// Will probably be available at some point, for now do the filtering ourselves.
assets := new(thirdparty.FullCollectibleDataContainer)
// Build map for more efficient contract address check
contractHashMap := make(map[string]bool)
for _, contractAddress := range contractAddresses {
contractID := thirdparty.ContractID{
ChainID: chainID,
Address: contractAddress,
}
contractHashMap[contractID.HashKey()] = true
}
assets.PreviousCursor = cursor
assets.NextCursor = cursor
assets.Provider = o.ID()
for {
assetsPage, err := o.FetchAllAssetsByOwner(ctx, chainID, owner, assets.NextCursor, assetLimitV2)
if err != nil {
return nil, err
}
for _, asset := range assetsPage.Items {
if contractHashMap[asset.CollectibleData.ID.ContractID.HashKey()] {
assets.Items = append(assets.Items, asset)
}
}
assets.NextCursor = assetsPage.NextCursor
if assets.NextCursor == "" {
break
}
if limit > thirdparty.FetchNoLimit && len(assets.Items) >= limit {
break
}
}
return assets, nil
}
func (o *ClientV2) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
pathParams := []string{
"chain", chainIDToChainString(chainID),
"account", owner.String(),
"nfts",
}
queryParams := url.Values{}
return o.fetchAssets(ctx, chainID, pathParams, queryParams, limit, cursor)
}
func (o *ClientV2) FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) {
return o.fetchDetailedAssets(ctx, uniqueIDs)
}
func (o *ClientV2) fetchAssets(ctx context.Context, chainID walletCommon.ChainID, pathParams []string, queryParams url.Values, limit int, cursor string) (*thirdparty.FullCollectibleDataContainer, error) {
assets := new(thirdparty.FullCollectibleDataContainer)
tmpLimit := assetLimitV2
if limit > thirdparty.FetchNoLimit && limit < tmpLimit {
tmpLimit = limit
}
queryParams["limit"] = []string{strconv.Itoa(tmpLimit)}
assets.PreviousCursor = cursor
if cursor != "" {
queryParams["next"] = []string{cursor}
}
assets.Provider = o.ID()
for {
path := fmt.Sprintf("%s?%s", strings.Join(pathParams, "/"), queryParams.Encode())
url, err := o.urlGetter(chainID, path)
if err != nil {
return nil, err
}
body, err := o.client.doGetRequest(ctx, url, o.apiKey)
if err != nil {
o.connectionStatus.SetIsConnected(false)
return nil, err
}
o.connectionStatus.SetIsConnected(true)
// If body is empty, it means the account has no collectibles for this chain.
// (Workaround implemented in http_client.go)
if body == nil {
assets.NextCursor = ""
break
}
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
container := NFTContainer{}
err = json.Unmarshal(body, &container)
if err != nil {
return nil, err
}
for _, asset := range container.NFTs {
assets.Items = append(assets.Items, asset.toCommon(chainID))
}
assets.NextCursor = container.NextCursor
if assets.NextCursor == "" {
break
}
queryParams["next"] = []string{assets.NextCursor}
if limit > thirdparty.FetchNoLimit && len(assets.Items) >= limit {
break
}
}
return assets, nil
}
func (o *ClientV2) fetchDetailedAssets(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) {
assets := make([]thirdparty.FullCollectibleData, 0, len(uniqueIDs))
for _, id := range uniqueIDs {
path := fmt.Sprintf("chain/%s/contract/%s/nfts/%s", chainIDToChainString(id.ContractID.ChainID), id.ContractID.Address.String(), id.TokenID.String())
url, err := o.urlGetter(id.ContractID.ChainID, path)
if err != nil {
return nil, err
}
body, err := o.client.doGetRequest(ctx, url, o.apiKey)
if err != nil {
if ctx.Err() == nil {
o.connectionStatus.SetIsConnected(false)
}
return nil, err
}
o.connectionStatus.SetIsConnected(true)
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
nftContainer := DetailedNFTContainer{}
err = json.Unmarshal(body, &nftContainer)
if err != nil {
return nil, err
}
assets = append(assets, nftContainer.NFT.toCommon(id.ContractID.ChainID))
}
return assets, nil
}
func (o *ClientV2) fetchContractDataByContractID(ctx context.Context, id thirdparty.ContractID) (*ContractData, error) {
path := fmt.Sprintf("chain/%s/contract/%s", chainIDToChainString(id.ChainID), id.Address.String())
url, err := o.urlGetter(id.ChainID, path)
if err != nil {
return nil, err
}
body, err := o.client.doGetRequest(ctx, url, o.apiKey)
if err != nil {
if ctx.Err() == nil {
o.connectionStatus.SetIsConnected(false)
}
return nil, err
}
o.connectionStatus.SetIsConnected(true)
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
contract := ContractData{}
err = json.Unmarshal(body, &contract)
if err != nil {
return nil, err
}
return &contract, nil
}
func (o *ClientV2) fetchCollectionDataBySlug(ctx context.Context, chainID walletCommon.ChainID, slug string) (*CollectionData, error) {
path := fmt.Sprintf("collections/%s", slug)
url, err := o.urlGetter(chainID, path)
if err != nil {
return nil, err
}
body, err := o.client.doGetRequest(ctx, url, o.apiKey)
if err != nil {
o.connectionStatus.SetIsConnected(false)
return nil, err
}
o.connectionStatus.SetIsConnected(true)
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
collection := CollectionData{}
err = json.Unmarshal(body, &collection)
if err != nil {
return nil, err
}
return &collection, nil
}
func (o *ClientV2) FetchCollectionsDataByContractID(ctx context.Context, contractIDs []thirdparty.ContractID) ([]thirdparty.CollectionData, error) {
ret := make([]thirdparty.CollectionData, 0, len(contractIDs))
for _, id := range contractIDs {
contractData, err := o.fetchContractDataByContractID(ctx, id)
if err != nil {
return nil, err
}
if contractData == nil || contractData.Collection == "" {
continue
}
collectionData, err := o.fetchCollectionDataBySlug(ctx, id.ChainID, contractData.Collection)
if err != nil {
return nil, err
}
ret = append(ret, collectionData.toCommon(id, contractData.ContractStandard))
}
return ret, nil
}

View File

@@ -0,0 +1,96 @@
package opensea
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"sync"
"time"
"github.com/ethereum/go-ethereum/log"
)
const requestTimeout = 5 * time.Second
const getRequestRetryMaxCount = 15
const getRequestWaitTime = 300 * time.Millisecond
type HTTPClient struct {
client *http.Client
getRequestLock sync.RWMutex
}
func NewHTTPClient() *HTTPClient {
return &HTTPClient{
client: &http.Client{
Timeout: requestTimeout,
},
}
}
func (o *HTTPClient) doGetRequest(ctx context.Context, url string, apiKey string) ([]byte, error) {
// Ensure only one thread makes a request at a time
o.getRequestLock.Lock()
defer o.getRequestLock.Unlock()
retryCount := 0
statusCode := http.StatusOK
// Try to do the request without an apiKey first
tmpAPIKey := ""
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:96.0) Gecko/20100101 Firefox/96.0")
if len(tmpAPIKey) > 0 {
req.Header.Set("X-API-KEY", tmpAPIKey)
}
resp, err := o.client.Do(req)
if err != nil {
return nil, err
}
defer func() {
if err := resp.Body.Close(); err != nil {
log.Error("failed to close opensea request body", "err", err)
}
}()
statusCode = resp.StatusCode
switch resp.StatusCode {
case http.StatusOK:
body, err := ioutil.ReadAll(resp.Body)
return body, err
case http.StatusBadRequest:
// The OpenSea v2 API will return error 400 if the account holds no collectibles on
// the requested chain. This shouldn't be treated as an error, return an empty body.
return nil, nil
case http.StatusTooManyRequests:
if retryCount < getRequestRetryMaxCount {
// sleep and retry
time.Sleep(getRequestWaitTime)
retryCount++
continue
}
// break and error
case http.StatusForbidden:
// Request requires an apiKey, set it and retry
if tmpAPIKey == "" && apiKey != "" {
tmpAPIKey = apiKey
// sleep and retry
time.Sleep(getRequestWaitTime)
continue
}
// break and error
default:
// break and error
}
break
}
return nil, fmt.Errorf("unsuccessful request: %d %s", statusCode, http.StatusText(statusCode))
}

View File

@@ -0,0 +1,238 @@
package opensea
import (
"encoding/json"
"strconv"
"strings"
"github.com/ethereum/go-ethereum/common"
"github.com/status-im/status-go/services/wallet/bigint"
walletCommon "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/thirdparty"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
const (
OpenseaV2ID = "openseaV2"
ethereumMainnetString = "ethereum"
arbitrumMainnetString = "arbitrum"
optimismMainnetString = "optimism"
ethereumSepoliaString = "sepolia"
arbitrumSepoliaString = "arbitrum_sepolia"
optimismSepoliaString = "optimism_sepolia"
)
type urlGetter func(walletCommon.ChainID, string) (string, error)
func chainIDToChainString(chainID walletCommon.ChainID) string {
chainString := ""
switch uint64(chainID) {
case walletCommon.EthereumMainnet:
chainString = ethereumMainnetString
case walletCommon.ArbitrumMainnet:
chainString = arbitrumMainnetString
case walletCommon.OptimismMainnet:
chainString = optimismMainnetString
case walletCommon.EthereumSepolia:
chainString = ethereumSepoliaString
case walletCommon.ArbitrumSepolia:
chainString = arbitrumSepoliaString
case walletCommon.OptimismSepolia:
chainString = optimismSepoliaString
}
return chainString
}
func openseaToContractType(contractType string) walletCommon.ContractType {
switch contractType {
case "cryptopunks", "erc721":
return walletCommon.ContractTypeERC721
case "erc1155":
return walletCommon.ContractTypeERC1155
default:
return walletCommon.ContractTypeUnknown
}
}
type NFTContainer struct {
NFTs []NFT `json:"nfts"`
NextCursor string `json:"next"`
}
type NFT struct {
TokenID *bigint.BigInt `json:"identifier"`
Collection string `json:"collection"`
Contract common.Address `json:"contract"`
TokenStandard string `json:"token_standard"`
Name string `json:"name"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
MetadataURL string `json:"metadata_url"`
}
type DetailedNFTContainer struct {
NFT DetailedNFT `json:"nft"`
}
type DetailedNFT struct {
TokenID *bigint.BigInt `json:"identifier"`
Collection string `json:"collection"`
Contract common.Address `json:"contract"`
TokenStandard string `json:"token_standard"`
Name string `json:"name"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
AnimationURL string `json:"animation_url"`
MetadataURL string `json:"metadata_url"`
Owners []OwnerV2 `json:"owners"`
Traits []TraitV2 `json:"traits"`
}
type OwnerV2 struct {
Address common.Address `json:"address"`
Quantity *bigint.BigInt `json:"quantity"`
}
type TraitValue string
func (st *TraitValue) UnmarshalJSON(b []byte) error {
var item interface{}
if err := json.Unmarshal(b, &item); err != nil {
return err
}
switch v := item.(type) {
case float64:
*st = TraitValue(strconv.FormatFloat(v, 'f', 2, 64))
case int:
*st = TraitValue(strconv.Itoa(v))
case string:
*st = TraitValue(v)
}
return nil
}
type TraitV2 struct {
TraitType string `json:"trait_type"`
DisplayType string `json:"display_type"`
MaxValue string `json:"max_value"`
TraitCount int `json:"trait_count"`
Order string `json:"order"`
Value TraitValue `json:"value"`
}
type ContractData struct {
Address common.Address `json:"address"`
Chain string `json:"chain"`
Collection string `json:"collection"`
ContractStandard string `json:"contract_standard"`
Name string `json:"name"`
}
type ContractID struct {
Address common.Address `json:"address"`
Chain string `json:"chain"`
}
type CollectionData struct {
Collection string `json:"collection"`
Name string `json:"name"`
Description string `json:"description"`
Owner common.Address `json:"owner"`
ImageURL string `json:"image_url"`
Contracts []ContractID `json:"contracts"`
}
func (c *NFT) id(chainID walletCommon.ChainID) thirdparty.CollectibleUniqueID {
return thirdparty.CollectibleUniqueID{
ContractID: thirdparty.ContractID{
ChainID: chainID,
Address: c.Contract,
},
TokenID: c.TokenID,
}
}
func (c *NFT) toCollectiblesData(chainID walletCommon.ChainID) thirdparty.CollectibleData {
return thirdparty.CollectibleData{
ID: c.id(chainID),
ContractType: openseaToContractType(c.TokenStandard),
Provider: OpenseaV2ID,
Name: c.Name,
Description: c.Description,
ImageURL: c.ImageURL,
AnimationURL: c.ImageURL,
Traits: make([]thirdparty.CollectibleTrait, 0),
TokenURI: c.MetadataURL,
}
}
func (c *NFT) toCommon(chainID walletCommon.ChainID) thirdparty.FullCollectibleData {
return thirdparty.FullCollectibleData{
CollectibleData: c.toCollectiblesData(chainID),
CollectionData: nil,
}
}
func openseaV2ToCollectibleTraits(traits []TraitV2) []thirdparty.CollectibleTrait {
ret := make([]thirdparty.CollectibleTrait, 0, len(traits))
caser := cases.Title(language.Und, cases.NoLower)
for _, orig := range traits {
dest := thirdparty.CollectibleTrait{
TraitType: strings.Replace(orig.TraitType, "_", " ", 1),
Value: caser.String(string(orig.Value)),
DisplayType: orig.DisplayType,
MaxValue: orig.MaxValue,
}
ret = append(ret, dest)
}
return ret
}
func (c *DetailedNFT) id(chainID walletCommon.ChainID) thirdparty.CollectibleUniqueID {
return thirdparty.CollectibleUniqueID{
ContractID: thirdparty.ContractID{
ChainID: chainID,
Address: c.Contract,
},
TokenID: c.TokenID,
}
}
func (c *DetailedNFT) toCollectiblesData(chainID walletCommon.ChainID) thirdparty.CollectibleData {
return thirdparty.CollectibleData{
ID: c.id(chainID),
ContractType: openseaToContractType(c.TokenStandard),
Provider: OpenseaV2ID,
Name: c.Name,
Description: c.Description,
ImageURL: c.ImageURL,
AnimationURL: c.AnimationURL,
Traits: openseaV2ToCollectibleTraits(c.Traits),
TokenURI: c.MetadataURL,
}
}
func (c *DetailedNFT) toCommon(chainID walletCommon.ChainID) thirdparty.FullCollectibleData {
return thirdparty.FullCollectibleData{
CollectibleData: c.toCollectiblesData(chainID),
CollectionData: nil,
}
}
func (c *CollectionData) toCommon(id thirdparty.ContractID, tokenStandard string) thirdparty.CollectionData {
ret := thirdparty.CollectionData{
ID: id,
ContractType: openseaToContractType(tokenStandard),
Provider: OpenseaV2ID,
Name: c.Name,
Slug: c.Collection,
ImageURL: c.ImageURL,
}
return ret
}