diff --git a/services/wallet/common/const.go b/services/wallet/common/const.go index fd30aba3c..122fd84a4 100644 --- a/services/wallet/common/const.go +++ b/services/wallet/common/const.go @@ -24,6 +24,18 @@ func (c ChainID) String() string { return strconv.Itoa(int(c)) } +func (c ChainID) IsMainnet() bool { + switch uint64(c) { + case EthereumMainnet, OptimismMainnet, ArbitrumMainnet: + return true + case EthereumGoerli, EthereumSepolia, OptimismGoerli, OptimismSepolia, ArbitrumGoerli, ArbitrumSepolia: + return false + case UnknownChainID: + return false + } + return false +} + func AllChainIDs() []ChainID { return []ChainID{ ChainID(EthereumMainnet), diff --git a/services/wallet/service.go b/services/wallet/service.go index 60d974eb1..9f9dae22a 100644 --- a/services/wallet/service.go +++ b/services/wallet/service.go @@ -29,6 +29,7 @@ import ( "github.com/status-im/status-go/services/wallet/thirdparty/coingecko" "github.com/status-im/status-go/services/wallet/thirdparty/cryptocompare" "github.com/status-im/status-go/services/wallet/thirdparty/opensea" + "github.com/status-im/status-go/services/wallet/thirdparty/rarible" "github.com/status-im/status-go/services/wallet/token" "github.com/status-im/status-go/services/wallet/transfer" "github.com/status-im/status-go/services/wallet/walletconnect" @@ -110,24 +111,29 @@ func NewService( openseaHTTPClient := opensea.NewHTTPClient() openseaV2Client := opensea.NewClientV2(config.WalletConfig.OpenseaAPIKey, openseaHTTPClient) + raribleClient := rarible.NewClient(config.WalletConfig.RaribleMainnetAPIKey, config.WalletConfig.RaribleTestnetAPIKey) alchemyClient := alchemy.NewClient(config.WalletConfig.AlchemyAPIKeys) // Try OpenSea, Infura, Alchemy in that order contractOwnershipProviders := []thirdparty.CollectibleContractOwnershipProvider{ + raribleClient, alchemyClient, } accountOwnershipProviders := []thirdparty.CollectibleAccountOwnershipProvider{ + raribleClient, openseaV2Client, alchemyClient, } collectibleDataProviders := []thirdparty.CollectibleDataProvider{ + raribleClient, openseaV2Client, alchemyClient, } collectionDataProviders := []thirdparty.CollectionDataProvider{ + raribleClient, openseaV2Client, alchemyClient, } diff --git a/services/wallet/thirdparty/collectible_types.go b/services/wallet/thirdparty/collectible_types.go index 9ae86f3d8..726b59c61 100644 --- a/services/wallet/thirdparty/collectible_types.go +++ b/services/wallet/thirdparty/collectible_types.go @@ -14,7 +14,8 @@ import ( ) var ( - ErrChainIDNotSupported = errors.New("chainID not supported") + ErrChainIDNotSupported = errors.New("chainID not supported") + ErrEndpointNotSupported = errors.New("endpoint not supported") ) const FetchNoLimit = 0 diff --git a/services/wallet/thirdparty/rarible/client.go b/services/wallet/thirdparty/rarible/client.go new file mode 100644 index 000000000..a97e487dd --- /dev/null +++ b/services/wallet/thirdparty/rarible/client.go @@ -0,0 +1,428 @@ +package rarible + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/cenkalti/backoff/v4" + + "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 ownedNFTLimit = 100 +const collectionOwnershipLimit = 50 +const nftMetadataBatchLimit = 50 + +func (o *Client) ID() string { + return RaribleID +} + +func (o *Client) IsChainSupported(chainID walletCommon.ChainID) bool { + _, err := getBaseURL(chainID) + return err == nil +} + +func (o *Client) IsConnected() bool { + return o.connectionStatus.IsConnected() +} + +func getBaseURL(chainID walletCommon.ChainID) (string, error) { + switch uint64(chainID) { + case walletCommon.EthereumMainnet, walletCommon.ArbitrumMainnet: + return "https://api.rarible.org", nil + case walletCommon.EthereumGoerli, walletCommon.ArbitrumSepolia: + return "https://testnet-api.rarible.org", nil + } + + return "", thirdparty.ErrChainIDNotSupported +} + +func getItemBaseURL(chainID walletCommon.ChainID) (string, error) { + baseURL, err := getBaseURL(chainID) + + if err != nil { + return "", err + } + + return fmt.Sprintf("%s/v0.1/items", baseURL), nil +} + +func getOwnershipBaseURL(chainID walletCommon.ChainID) (string, error) { + baseURL, err := getBaseURL(chainID) + + if err != nil { + return "", err + } + + return fmt.Sprintf("%s/v0.1/ownerships", baseURL), nil +} + +func getCollectionBaseURL(chainID walletCommon.ChainID) (string, error) { + baseURL, err := getBaseURL(chainID) + + if err != nil { + return "", err + } + + return fmt.Sprintf("%s/v0.1/collections", baseURL), nil +} + +type Client struct { + thirdparty.CollectibleContractOwnershipProvider + client *http.Client + mainnetAPIKey string + testnetAPIKey string + connectionStatus *connection.Status +} + +func NewClient(mainnetAPIKey string, testnetAPIKey string) *Client { + if mainnetAPIKey == "" { + log.Warn("Rarible API key not available for Mainnet") + } + + if testnetAPIKey == "" { + log.Warn("Rarible API key not available for Testnet") + } + + return &Client{ + client: &http.Client{Timeout: time.Minute}, + mainnetAPIKey: mainnetAPIKey, + testnetAPIKey: testnetAPIKey, + connectionStatus: connection.NewStatus(), + } +} + +func (o *Client) getAPIKey(chainID walletCommon.ChainID) string { + if chainID.IsMainnet() { + return o.mainnetAPIKey + } + return o.testnetAPIKey +} + +func (o *Client) doQuery(ctx context.Context, url string, apiKey string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("content-type", "application/json") + + return o.doWithRetries(req, apiKey) +} + +func (o *Client) doPostWithJSON(ctx context.Context, url string, payload any, apiKey string) (*http.Response, error) { + payloadJSON, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + payloadString := string(payloadJSON) + payloadReader := strings.NewReader(payloadString) + + req, err := http.NewRequestWithContext(ctx, "POST", url, payloadReader) + if err != nil { + return nil, err + } + + req.Header.Add("accept", "application/json") + req.Header.Add("content-type", "application/json") + + return o.doWithRetries(req, apiKey) +} + +func (o *Client) doWithRetries(req *http.Request, apiKey string) (*http.Response, error) { + b := backoff.ExponentialBackOff{ + InitialInterval: time.Millisecond * 1000, + RandomizationFactor: 0.1, + Multiplier: 1.5, + MaxInterval: time.Second * 32, + MaxElapsedTime: time.Second * 128, + Clock: backoff.SystemClock, + } + b.Reset() + + req.Header.Set("X-API-KEY", apiKey) + + op := func() (*http.Response, error) { + resp, err := o.client.Do(req) + if err != nil { + return nil, backoff.Permanent(err) + } + + if resp.StatusCode == http.StatusOK { + return resp, nil + } + + err = fmt.Errorf("unsuccessful request: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode)) + if resp.StatusCode == http.StatusTooManyRequests { + return nil, err + } + return nil, backoff.Permanent(err) + } + + return backoff.RetryWithData(op, &b) +} + +func (o *Client) FetchCollectibleOwnersByContractAddress(ctx context.Context, chainID walletCommon.ChainID, contractAddress common.Address) (*thirdparty.CollectibleContractOwnership, error) { + ownership := thirdparty.CollectibleContractOwnership{ + ContractAddress: contractAddress, + Owners: make([]thirdparty.CollectibleOwner, 0), + } + + queryParams := url.Values{ + "collection": {fmt.Sprintf("%s:%s", chainIDToChainString(chainID), contractAddress.String())}, + "size": {strconv.Itoa(collectionOwnershipLimit)}, + } + + baseURL, err := getOwnershipBaseURL(chainID) + + if err != nil { + return nil, err + } + + for { + url := fmt.Sprintf("%s/byCollection?%s", baseURL, queryParams.Encode()) + + resp, err := o.doQuery(ctx, url, o.getAPIKey(chainID)) + if err != nil { + o.connectionStatus.SetIsConnected(false) + return nil, err + } + o.connectionStatus.SetIsConnected(true) + + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var raribleOwnership ContractOwnershipContainer + err = json.Unmarshal(body, &raribleOwnership) + if err != nil { + return nil, err + } + + ownership.Owners = append(ownership.Owners, raribleContractOwnershipsToCommon(raribleOwnership.Ownerships)...) + + if raribleOwnership.Continuation == "" { + break + } + + queryParams["continuation"] = []string{raribleOwnership.Continuation} + } + + return &ownership, nil +} + +func (o *Client) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) { + assets := new(thirdparty.FullCollectibleDataContainer) + + queryParams := url.Values{ + "owner": {fmt.Sprintf("%s:%s", ethereumString, owner.String())}, + "blockchains": {chainIDToChainString(chainID)}, + } + + tmpLimit := ownedNFTLimit + if limit > thirdparty.FetchNoLimit && limit < tmpLimit { + tmpLimit = limit + } + queryParams["size"] = []string{strconv.Itoa(tmpLimit)} + + if len(cursor) > 0 { + queryParams["continuation"] = []string{cursor} + assets.PreviousCursor = cursor + } + assets.Provider = o.ID() + + baseURL, err := getItemBaseURL(chainID) + + if err != nil { + return nil, err + } + + for { + url := fmt.Sprintf("%s/byOwner?%s", baseURL, queryParams.Encode()) + + resp, err := o.doQuery(ctx, url, o.getAPIKey(chainID)) + if err != nil { + o.connectionStatus.SetIsConnected(false) + return nil, err + } + o.connectionStatus.SetIsConnected(true) + + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // if Json is not returned there must be an error + if !json.Valid(body) { + return nil, fmt.Errorf("invalid json: %s", string(body)) + } + + var container CollectiblesContainer + err = json.Unmarshal(body, &container) + if err != nil { + return nil, err + } + + assets.Items = append(assets.Items, raribleToCollectiblesData(container.Collectibles, chainID.IsMainnet())...) + assets.NextCursor = container.Continuation + + if len(assets.NextCursor) == 0 { + break + } + + queryParams["continuation"] = []string{assets.NextCursor} + + if limit != thirdparty.FetchNoLimit && len(assets.Items) >= limit { + break + } + } + + return assets, nil +} + +func (o *Client) FetchAllAssetsByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) { + return nil, thirdparty.ErrEndpointNotSupported +} + +func getCollectibleUniqueIDBatches(ids []thirdparty.CollectibleUniqueID) []BatchTokenIDs { + batches := make([]BatchTokenIDs, 0) + + for startIdx := 0; startIdx < len(ids); startIdx += nftMetadataBatchLimit { + endIdx := startIdx + nftMetadataBatchLimit + if endIdx > len(ids) { + endIdx = len(ids) + } + + pageIDs := ids[startIdx:endIdx] + + batchIDs := BatchTokenIDs{ + IDs: make([]string, 0, len(pageIDs)), + } + for _, id := range pageIDs { + batchID := fmt.Sprintf("%s:%s:%s", chainIDToChainString(id.ContractID.ChainID), id.ContractID.Address.String(), id.TokenID.String()) + batchIDs.IDs = append(batchIDs.IDs, batchID) + } + + batches = append(batches, batchIDs) + } + + return batches +} + +func (o *Client) fetchAssetsByBatchTokenIDs(ctx context.Context, chainID walletCommon.ChainID, batchIDs BatchTokenIDs) ([]thirdparty.FullCollectibleData, error) { + baseURL, err := getItemBaseURL(chainID) + + if err != nil { + return nil, err + } + + url := fmt.Sprintf("%s/byIds", baseURL) + + resp, err := o.doPostWithJSON(ctx, url, batchIDs, o.getAPIKey(chainID)) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // if Json is not returned there must be an error + if !json.Valid(body) { + return nil, fmt.Errorf("invalid json: %s", string(body)) + } + + var assets CollectiblesContainer + err = json.Unmarshal(body, &assets) + if err != nil { + return nil, err + } + + ret := raribleToCollectiblesData(assets.Collectibles, chainID.IsMainnet()) + + return ret, nil +} + +func (o *Client) FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) { + ret := make([]thirdparty.FullCollectibleData, 0, len(uniqueIDs)) + + idsPerChainID := thirdparty.GroupCollectibleUIDsByChainID(uniqueIDs) + + for chainID, ids := range idsPerChainID { + batches := getCollectibleUniqueIDBatches(ids) + for _, batch := range batches { + assets, err := o.fetchAssetsByBatchTokenIDs(ctx, chainID, batch) + if err != nil { + return nil, err + } + + ret = append(ret, assets...) + } + } + + return ret, nil +} + +func (o *Client) FetchCollectionsDataByContractID(ctx context.Context, contractIDs []thirdparty.ContractID) ([]thirdparty.CollectionData, error) { + ret := make([]thirdparty.CollectionData, 0, len(contractIDs)) + + for _, contractID := range contractIDs { + baseURL, err := getCollectionBaseURL(contractID.ChainID) + + if err != nil { + return nil, err + } + + url := fmt.Sprintf("%s/%s:%s", baseURL, chainIDToChainString(contractID.ChainID), contractID.Address.String()) + + resp, err := o.doQuery(ctx, url, o.getAPIKey(contractID.ChainID)) + if err != nil { + o.connectionStatus.SetIsConnected(false) + return nil, err + } + o.connectionStatus.SetIsConnected(true) + + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // if Json is not returned there must be an error + if !json.Valid(body) { + return nil, fmt.Errorf("invalid json: %s", string(body)) + } + + var collection Collection + err = json.Unmarshal(body, &collection) + if err != nil { + return nil, err + } + + ret = append(ret, collection.toCommon(contractID)) + } + + return ret, nil +} diff --git a/services/wallet/thirdparty/rarible/client_test.go b/services/wallet/thirdparty/rarible/client_test.go new file mode 100644 index 000000000..201d277bd --- /dev/null +++ b/services/wallet/thirdparty/rarible/client_test.go @@ -0,0 +1,120 @@ +package rarible + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/status-im/status-go/services/wallet/bigint" + "github.com/status-im/status-go/services/wallet/thirdparty" + + "github.com/stretchr/testify/assert" +) + +func TestUnmarshallCollection(t *testing.T) { + expectedCollectionData := thirdparty.CollectionData{ + ID: thirdparty.ContractID{ + ChainID: 1, + Address: common.HexToAddress("0x06012c8cf97bead5deae237070f9587f8e7a266d"), + }, + Provider: "rarible", + Name: "CryptoKitties", + ImageURL: "https://i.seadn.io/gae/C272ZRW1RGGef9vKMePFSCeKc1Lw6U40wl9ofNVxzUxFdj84hH9xJRQNf-7wgs7W8qw8RWe-1ybKp-VKuU5D-tg?w=500&auto=format", + Traits: make(map[string]thirdparty.CollectionTrait), + } + + collection := Collection{} + err := json.Unmarshal([]byte(collectionJSON), &collection) + assert.NoError(t, err) + + contractID, err := raribleContractIDToUniqueID(collection.ID, true) + assert.NoError(t, err) + + collectionData := collection.toCommon(contractID) + assert.Equal(t, expectedCollectionData, collectionData) +} + +func TestUnmarshallOwnedCollectibles(t *testing.T) { + expectedTokenID0, _ := big.NewInt(0).SetString("32292934596187112148346015918544186536963932779440027682601542850818403729416", 10) + expectedTokenID1, _ := big.NewInt(0).SetString("32292934596187112148346015918544186536963932779440027682601542850818403729414", 10) + + expectedCollectiblesData := []thirdparty.FullCollectibleData{ + { + CollectibleData: thirdparty.CollectibleData{ + ID: thirdparty.CollectibleUniqueID{ + ContractID: thirdparty.ContractID{ + ChainID: 1, + Address: common.HexToAddress("0xb66a603f4cfe17e3d27b87a8bfcad319856518b8"), + }, + TokenID: &bigint.BigInt{ + Int: expectedTokenID0, + }, + }, + Provider: "rarible", + Name: "Rariversary #002", + Description: "Today marks your Second Rariversary! Can you believe it’s already been two years? Time flies when you’re having fun! Thank you for everything you contribute!", + Permalink: "https://rarible.com/token/0xb66a603f4cfe17e3d27b87a8bfcad319856518b8:32292934596187112148346015918544186536963932779440027682601542850818403729416", + ImageURL: "https://lh3.googleusercontent.com/03DCIWuHtWUG5zIPAkdBjPAucg-BNu-917hsY1LRyEtG9pMcYSwIv5n_jZoK4bvMjNbw9MEC3AZA29kje83fCf2XwG6WegOv0JU=s1000", + AnimationURL: "https://ipfs.raribleuserdata.com/ipfs/bafybeibpqyrvdkw7ypajsmsvjiz2mhytv7fyyfa6n35tfui7e473dxnyom/image.png", + Traits: []thirdparty.CollectibleTrait{ + { + TraitType: "Theme", + Value: "Luv U", + }, + { + TraitType: "Gift for", + Value: "Rariversary", + }, + { + TraitType: "Year", + Value: "2", + }, + }, + TokenURI: "ipfs://ipfs/bafkreialxjfvfkn43jluxmilfg3d3ojnomtqg634nuowqq2syx4odqrx5m", + }, + }, + { + CollectibleData: thirdparty.CollectibleData{ + ID: thirdparty.CollectibleUniqueID{ + ContractID: thirdparty.ContractID{ + ChainID: 1, + Address: common.HexToAddress("0xb66a603f4cfe17e3d27b87a8bfcad319856518b8"), + }, + TokenID: &bigint.BigInt{ + Int: expectedTokenID1, + }, + }, + Provider: "rarible", + Name: "Rariversary #003", + Description: "Today marks your Third Rariversary! Can you believe it’s already been three years? Time flies when you’re having fun! We’ve loved working with you these years and can’t wait to see what the next few years bring. Thank you for everything you contribute!", + Permalink: "https://rarible.com/token/0xb66a603f4cfe17e3d27b87a8bfcad319856518b8:32292934596187112148346015918544186536963932779440027682601542850818403729414", + ImageURL: "https://lh3.googleusercontent.com/SimzYIBjaTFt3BTBXFGOOvAqfw_etV0Pbe2pen-IvwF7L8DOysNca7qBdj3Dt5n_HWsse5vDLD7FZ7o5XdEivRvBtUybI1mXZEBQ=s1000", + AnimationURL: "https://ipfs.raribleuserdata.com/ipfs/bafybeicsr36faeleunc5pzkqyf57pwm66vir4xhdkvv6cnkkznoyewqt7u/image.png", + Traits: []thirdparty.CollectibleTrait{ + { + TraitType: "Theme", + Value: "LFG", + }, + { + TraitType: "Gift for", + Value: "Rariversary", + }, + { + TraitType: "Year", + Value: "3", + }, + }, + TokenURI: "ipfs://ipfs/bafkreifeaueluerp33pjevz56f3ioxv63z73zuvm4wku5k6sobvala4phe", + }, + }, + } + + var container CollectiblesContainer + err := json.Unmarshal([]byte(ownedCollectiblesJSON), &container) + assert.NoError(t, err) + + collectiblesData := raribleToCollectiblesData(container.Collectibles, true) + + assert.Equal(t, expectedCollectiblesData, collectiblesData) +} diff --git a/services/wallet/thirdparty/rarible/client_test_data.go b/services/wallet/thirdparty/rarible/client_test_data.go new file mode 100644 index 000000000..a95a2a21b --- /dev/null +++ b/services/wallet/thirdparty/rarible/client_test_data.go @@ -0,0 +1,227 @@ +package rarible + +const collectionJSON = `{ + "id": "ETHEREUM:0x06012c8cf97bead5deae237070f9587f8e7a266d", + "blockchain": "ETHEREUM", + "structure": "REGULAR", + "type": "ERC721", + "status": "CONFIRMED", + "name": "CryptoKitties", + "symbol": "CK", + "features": [], + "minters": [], + "meta": { + "name": "CryptoKitties", + "description": "CryptoKitties is a game centered around breedable, collectible, and oh-so-adorable creatures we call CryptoKitties! Each cat is one-of-a-kind and 100% owned by you; it cannot be replicated, taken away, or destroyed.", + "tags": [], + "genres": [], + "externalUri": "https://www.cryptokitties.co/", + "content": [ + { + "@type": "IMAGE", + "url": "https://i.seadn.io/gae/C272ZRW1RGGef9vKMePFSCeKc1Lw6U40wl9ofNVxzUxFdj84hH9xJRQNf-7wgs7W8qw8RWe-1ybKp-VKuU5D-tg?w=500&auto=format", + "representation": "ORIGINAL", + "mimeType": "image/png", + "size": 7584, + "available": true, + "width": 250, + "height": 246 + } + ], + "externalLink": "https://www.cryptokitties.co/", + "sellerFeeBasisPoints": 250 + }, + "originOrders": [], + "self": false, + "scam": false + }` + +const ownedCollectiblesJSON = `{ + "continuation": "1603836283000_ETHEREUM:0xd07dc4262bcdbf85190c01c996b4c06a461d2430:63879:0x4765273c477c2dc484da4f1984639e943adccfeb", + "items": [ + { + "id": "ETHEREUM:0xb66a603f4cfe17e3d27b87a8bfcad319856518b8:32292934596187112148346015918544186536963932779440027682601542850818403729416", + "blockchain": "ETHEREUM", + "collection": "ETHEREUM:0xb66a603f4cfe17e3d27b87a8bfcad319856518b8", + "contract": "ETHEREUM:0xb66a603f4cfe17e3d27b87a8bfcad319856518b8", + "tokenId": "32292934596187112148346015918544186536963932779440027682601542850818403729416", + "creators": [ + { + "account": "ETHEREUM:0x4765273c477c2dc484da4f1984639e943adccfeb", + "value": 10000 + } + ], + "lazySupply": "0", + "pending": [], + "mintedAt": "2023-06-01T10:03:23Z", + "lastUpdatedAt": "2023-06-01T10:03:38.923Z", + "supply": "100", + "meta": { + "name": "Rariversary #002", + "description": "Today marks your Second Rariversary! Can you believe it’s already been two years? Time flies when you’re having fun! Thank you for everything you contribute!", + "createdAt": "2023-06-01T10:03:23Z", + "tags": [], + "genres": [], + "externalUri": "https://rarible.com/token/0xb66a603f4cfe17e3d27b87a8bfcad319856518b8:32292934596187112148346015918544186536963932779440027682601542850818403729416", + "originalMetaUri": "ipfs://ipfs/bafkreialxjfvfkn43jluxmilfg3d3ojnomtqg634nuowqq2syx4odqrx5m", + "attributes": [ + { + "key": "Theme", + "value": "luv u" + }, + { + "key": "Gift for", + "value": "Rariversary" + }, + { + "key": "Year", + "value": "2" + } + ], + "content": [ + { + "@type": "IMAGE", + "url": "https://ipfs.raribleuserdata.com/ipfs/bafybeibpqyrvdkw7ypajsmsvjiz2mhytv7fyyfa6n35tfui7e473dxnyom/image.png", + "representation": "ORIGINAL", + "mimeType": "image/png", + "size": 4675851, + "available": true, + "width": 2000, + "height": 2000 + }, + { + "@type": "IMAGE", + "url": "https://lh3.googleusercontent.com/03DCIWuHtWUG5zIPAkdBjPAucg-BNu-917hsY1LRyEtG9pMcYSwIv5n_jZoK4bvMjNbw9MEC3AZA29kje83fCf2XwG6WegOv0JU=s1000", + "representation": "BIG", + "mimeType": "image/png", + "size": 1216435, + "available": true, + "width": 1000, + "height": 1000 + }, + { + "@type": "IMAGE", + "url": "https://lh3.googleusercontent.com/03DCIWuHtWUG5zIPAkdBjPAucg-BNu-917hsY1LRyEtG9pMcYSwIv5n_jZoK4bvMjNbw9MEC3AZA29kje83fCf2XwG6WegOv0JU=k-w1200-s2400-rj", + "representation": "PORTRAIT", + "mimeType": "image/jpeg", + "size": 191288, + "available": true, + "width": 1200, + "height": 1200 + }, + { + "@type": "IMAGE", + "url": "https://lh3.googleusercontent.com/03DCIWuHtWUG5zIPAkdBjPAucg-BNu-917hsY1LRyEtG9pMcYSwIv5n_jZoK4bvMjNbw9MEC3AZA29kje83fCf2XwG6WegOv0JU=s250", + "representation": "PREVIEW", + "mimeType": "image/png", + "size": 96841, + "available": true, + "width": 250, + "height": 250 + } + ] + }, + "deleted": false, + "originOrders": [], + "ammOrders": { + "ids": [] + }, + "auctions": [], + "totalStock": "0", + "sellers": 0, + "suspicious": false + }, + { + "id": "ETHEREUM:0xb66a603f4cfe17e3d27b87a8bfcad319856518b8:32292934596187112148346015918544186536963932779440027682601542850818403729414", + "blockchain": "ETHEREUM", + "collection": "ETHEREUM:0xb66a603f4cfe17e3d27b87a8bfcad319856518b8", + "contract": "ETHEREUM:0xb66a603f4cfe17e3d27b87a8bfcad319856518b8", + "tokenId": "32292934596187112148346015918544186536963932779440027682601542850818403729414", + "creators": [ + { + "account": "ETHEREUM:0x4765273c477c2dc484da4f1984639e943adccfeb", + "value": 10000 + } + ], + "lazySupply": "0", + "pending": [], + "mintedAt": "2023-06-01T09:43:35Z", + "lastUpdatedAt": "2023-06-01T09:43:41.498Z", + "supply": "100", + "meta": { + "name": "Rariversary #003", + "description": "Today marks your Third Rariversary! Can you believe it’s already been three years? Time flies when you’re having fun! We’ve loved working with you these years and can’t wait to see what the next few years bring. Thank you for everything you contribute!", + "createdAt": "2023-06-01T09:43:35Z", + "tags": [], + "genres": [], + "externalUri": "https://rarible.com/token/0xb66a603f4cfe17e3d27b87a8bfcad319856518b8:32292934596187112148346015918544186536963932779440027682601542850818403729414", + "originalMetaUri": "ipfs://ipfs/bafkreifeaueluerp33pjevz56f3ioxv63z73zuvm4wku5k6sobvala4phe", + "attributes": [ + { + "key": "Theme", + "value": "LFG" + }, + { + "key": "Gift for", + "value": "Rariversary" + }, + { + "key": "Year", + "value": "3" + } + ], + "content": [ + { + "@type": "IMAGE", + "url": "https://ipfs.raribleuserdata.com/ipfs/bafybeicsr36faeleunc5pzkqyf57pwm66vir4xhdkvv6cnkkznoyewqt7u/image.png", + "representation": "ORIGINAL", + "mimeType": "image/png", + "size": 3742351, + "available": true, + "width": 2000, + "height": 2000 + }, + { + "@type": "IMAGE", + "url": "https://lh3.googleusercontent.com/SimzYIBjaTFt3BTBXFGOOvAqfw_etV0Pbe2pen-IvwF7L8DOysNca7qBdj3Dt5n_HWsse5vDLD7FZ7o5XdEivRvBtUybI1mXZEBQ=s1000", + "representation": "BIG", + "mimeType": "image/png", + "size": 988277, + "available": true, + "width": 1000, + "height": 1000 + }, + { + "@type": "IMAGE", + "url": "https://lh3.googleusercontent.com/SimzYIBjaTFt3BTBXFGOOvAqfw_etV0Pbe2pen-IvwF7L8DOysNca7qBdj3Dt5n_HWsse5vDLD7FZ7o5XdEivRvBtUybI1mXZEBQ=k-w1200-s2400-rj", + "representation": "PORTRAIT", + "mimeType": "image/jpeg", + "size": 224410, + "available": true, + "width": 1200, + "height": 1200 + }, + { + "@type": "IMAGE", + "url": "https://lh3.googleusercontent.com/SimzYIBjaTFt3BTBXFGOOvAqfw_etV0Pbe2pen-IvwF7L8DOysNca7qBdj3Dt5n_HWsse5vDLD7FZ7o5XdEivRvBtUybI1mXZEBQ=s250", + "representation": "PREVIEW", + "mimeType": "image/png", + "size": 68280, + "available": true, + "width": 250, + "height": 250 + } + ] + }, + "deleted": false, + "originOrders": [], + "ammOrders": { + "ids": [] + }, + "auctions": [], + "totalStock": "0", + "sellers": 0, + "suspicious": false + } + ] + }` diff --git a/services/wallet/thirdparty/rarible/types.go b/services/wallet/thirdparty/rarible/types.go new file mode 100644 index 000000000..b0629abde --- /dev/null +++ b/services/wallet/thirdparty/rarible/types.go @@ -0,0 +1,345 @@ +package rarible + +import ( + "encoding/json" + "fmt" + "math/big" + "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 RaribleID = "rarible" + +const ( + ethereumString = "ETHEREUM" + arbitrumString = "ARBITRUM" +) + +func chainStringToChainID(chainString string, isMainnet bool) walletCommon.ChainID { + chainID := walletCommon.UnknownChainID + switch chainString { + case ethereumString: + if isMainnet { + chainID = walletCommon.EthereumMainnet + } else { + chainID = walletCommon.EthereumGoerli + } + case arbitrumString: + if isMainnet { + chainID = walletCommon.ArbitrumMainnet + } else { + chainID = walletCommon.ArbitrumSepolia + } + } + return walletCommon.ChainID(chainID) +} + +func chainIDToChainString(chainID walletCommon.ChainID) string { + chainString := "" + switch uint64(chainID) { + case walletCommon.EthereumMainnet, walletCommon.EthereumGoerli: + chainString = ethereumString + case walletCommon.ArbitrumMainnet, walletCommon.ArbitrumSepolia: + chainString = arbitrumString + } + return chainString +} + +func raribleContractIDToUniqueID(contractID string, isMainnet bool) (thirdparty.ContractID, error) { + ret := thirdparty.ContractID{} + + parts := strings.Split(contractID, ":") + if len(parts) != 2 { + return ret, fmt.Errorf("invalid rarible contract id string %s", contractID) + } + + ret.ChainID = chainStringToChainID(parts[0], isMainnet) + if uint64(ret.ChainID) == walletCommon.UnknownChainID { + return ret, fmt.Errorf("unknown rarible chainID in contract id string %s", contractID) + } + ret.Address = common.HexToAddress(parts[1]) + + return ret, nil +} + +func raribleCollectibleIDToUniqueID(collectibleID string, isMainnet bool) (thirdparty.CollectibleUniqueID, error) { + ret := thirdparty.CollectibleUniqueID{} + + parts := strings.Split(collectibleID, ":") + if len(parts) != 3 { + return ret, fmt.Errorf("invalid rarible collectible id string %s", collectibleID) + } + + ret.ContractID.ChainID = chainStringToChainID(parts[0], isMainnet) + if uint64(ret.ContractID.ChainID) == walletCommon.UnknownChainID { + return ret, fmt.Errorf("unknown rarible chainID in collectible id string %s", collectibleID) + } + ret.ContractID.Address = common.HexToAddress(parts[1]) + tokenID, ok := big.NewInt(0).SetString(parts[2], 10) + if !ok { + return ret, fmt.Errorf("invalid rarible tokenID %s", collectibleID) + } + ret.TokenID = &bigint.BigInt{ + Int: tokenID, + } + + return ret, nil +} + +type BatchTokenIDs struct { + IDs []string `json:"ids"` +} + +type CollectiblesContainer struct { + Continuation string `json:"continuation"` + Collectibles []Collectible `json:"items"` +} + +type Collectible struct { + ID string `json:"id"` + Blockchain string `json:"blockchain"` + Collection string `json:"collection"` + Contract string `json:"contract"` + TokenID *bigint.BigInt `json:"tokenId"` + Metadata CollectibleMetadata `json:"meta"` +} + +type CollectibleMetadata struct { + Name string `json:"name"` + Description string `json:"description"` + ExternalURI string `json:"externalUri"` + OriginalMetaURI string `json:"originalMetaUri"` + Attributes []Attribute `json:"attributes"` + Contents []Content `json:"content"` +} + +type Attribute struct { + Key string `json:"key"` + Value AttributeValue `json:"value"` +} + +type AttributeValue string + +func (st *AttributeValue) UnmarshalJSON(b []byte) error { + var item interface{} + if err := json.Unmarshal(b, &item); err != nil { + return err + } + + switch v := item.(type) { + case float64: + *st = AttributeValue(strconv.FormatFloat(v, 'f', 2, 64)) + case int: + *st = AttributeValue(strconv.Itoa(v)) + case string: + *st = AttributeValue(v) + } + return nil +} + +type Collection struct { + ID string `json:"id"` + Blockchain string `json:"blockchain"` + Name string `json:"name"` + Metadata CollectionMetadata `json:"meta"` +} + +type CollectionMetadata struct { + Name string `json:"name"` + Description string `json:"description"` + Contents []Content `json:"content"` +} + +type Content struct { + Type string `json:"@type"` + URL string `json:"url"` + Representation string `json:"representation"` + Available bool `json:"available"` +} + +type ContractOwnershipContainer struct { + Continuation string `json:"continuation"` + Ownerships []ContractOwnership `json:"ownerships"` +} + +type ContractOwnership struct { + ID string `json:"id"` + Blockchain string `json:"blockchain"` + ItemID string `json:"itemId"` + Contract string `json:"contract"` + Collection string `json:"collection"` + TokenID *bigint.BigInt `json:"tokenId"` + Owner string `json:"owner"` + Value *bigint.BigInt `json:"value"` +} + +func raribleContractOwnershipsToCommon(raribleOwnerships []ContractOwnership) []thirdparty.CollectibleOwner { + balancesPerOwner := make(map[common.Address][]thirdparty.TokenBalance) + for _, raribleOwnership := range raribleOwnerships { + owner := common.HexToAddress(raribleOwnership.Owner) + if _, ok := balancesPerOwner[owner]; !ok { + balancesPerOwner[owner] = make([]thirdparty.TokenBalance, 0) + } + + balance := thirdparty.TokenBalance{ + TokenID: raribleOwnership.TokenID, + Balance: raribleOwnership.Value, + } + balancesPerOwner[owner] = append(balancesPerOwner[owner], balance) + } + + ret := make([]thirdparty.CollectibleOwner, 0, len(balancesPerOwner)) + for owner, balances := range balancesPerOwner { + ret = append(ret, thirdparty.CollectibleOwner{ + OwnerAddress: owner, + TokenBalances: balances, + }) + } + + return ret +} + +func raribleToCollectibleTraits(attributes []Attribute) []thirdparty.CollectibleTrait { + ret := make([]thirdparty.CollectibleTrait, 0, len(attributes)) + caser := cases.Title(language.Und, cases.NoLower) + for _, orig := range attributes { + dest := thirdparty.CollectibleTrait{ + TraitType: orig.Key, + Value: caser.String(string(orig.Value)), + } + + ret = append(ret, dest) + } + return ret +} + +func raribleToCollectiblesData(l []Collectible, isMainnet bool) []thirdparty.FullCollectibleData { + ret := make([]thirdparty.FullCollectibleData, 0, len(l)) + for _, c := range l { + id, err := raribleCollectibleIDToUniqueID(c.ID, isMainnet) + if err != nil { + continue + } + item := c.toCommon(id) + ret = append(ret, item) + } + return ret +} + +func (c *Collection) toCommon(id thirdparty.ContractID) thirdparty.CollectionData { + ret := thirdparty.CollectionData{ + ID: id, + Provider: RaribleID, + Name: c.Metadata.Name, + Slug: "", /* Missing from the API for now */ + ImageURL: getImageURL(c.Metadata.Contents), + Traits: make(map[string]thirdparty.CollectionTrait, 0), /* Missing from the API for now */ + } + return ret +} + +func contentTypeValue(contentType string, includeOriginal bool) int { + ret := -1 + + switch contentType { + case "PREVIEW": + ret = 1 + case "PORTRAIT": + ret = 2 + case "BIG": + ret = 3 + case "ORIGINAL": + if includeOriginal { + ret = 4 + } + } + + return ret +} + +func isNewContentBigger(current string, new string, includeOriginal bool) bool { + currentValue := contentTypeValue(current, includeOriginal) + newValue := contentTypeValue(new, includeOriginal) + + return newValue > currentValue +} + +func getBiggestContentURL(contents []Content, contentType string, includeOriginal bool) string { + ret := Content{ + Type: "", + URL: "", + Representation: "", + Available: false, + } + + for _, content := range contents { + if content.Type == contentType { + if isNewContentBigger(ret.Representation, content.Representation, includeOriginal) { + ret = content + } + } + } + + return ret.URL +} + +func getAnimationURL(contents []Content) string { + // Try to get the biggest content of type "VIDEO" + ret := getBiggestContentURL(contents, "VIDEO", true) + + // If empty, try to get the biggest content of type "IMAGE", including the "ORIGINAL" representation + if ret == "" { + ret = getBiggestContentURL(contents, "IMAGE", true) + } + + return ret +} + +func getImageURL(contents []Content) string { + // Get the biggest content of type "IMAGE", excluding the "ORIGINAL" representation + ret := getBiggestContentURL(contents, "IMAGE", false) + + // If empty, allow the "ORIGINAL" representation + if ret == "" { + ret = getBiggestContentURL(contents, "IMAGE", true) + } + + return ret +} + +func (c *Collectible) toCollectibleData(id thirdparty.CollectibleUniqueID) thirdparty.CollectibleData { + imageURL := getImageURL(c.Metadata.Contents) + animationURL := getAnimationURL(c.Metadata.Contents) + + if animationURL == "" { + animationURL = imageURL + } + + return thirdparty.CollectibleData{ + ID: id, + Provider: RaribleID, + Name: c.Metadata.Name, + Description: c.Metadata.Description, + Permalink: c.Metadata.ExternalURI, + ImageURL: imageURL, + AnimationURL: animationURL, + Traits: raribleToCollectibleTraits(c.Metadata.Attributes), + TokenURI: c.Metadata.OriginalMetaURI, + } +} + +func (c *Collectible) toCommon(id thirdparty.CollectibleUniqueID) thirdparty.FullCollectibleData { + return thirdparty.FullCollectibleData{ + CollectibleData: c.toCollectibleData(id), + CollectionData: nil, + } +}