feat: implement support for collectible provider Rarible

This commit is contained in:
Dario Gabriel Lipicar 2023-11-23 18:11:18 -03:00 committed by dlipicar
parent 8848943422
commit 472e4bdb77
7 changed files with 1140 additions and 1 deletions

View File

@ -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),

View File

@ -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,
}

View File

@ -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

View File

@ -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
}

View File

@ -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 its already been two years? Time flies when youre 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 its already been three years? Time flies when youre having fun! Weve loved working with you these years and cant 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)
}

View File

@ -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 its already been two years? Time flies when youre 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 its already been three years? Time flies when youre having fun! Weve loved working with you these years and cant 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
}
]
}`

View File

@ -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,
}
}