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
@@ -0,0 +1,459 @@
package alchemy
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"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 nftMetadataBatchLimit = 100
const contractMetadataBatchLimit = 100
func getBaseURL(chainID walletCommon.ChainID) (string, error) {
switch uint64(chainID) {
case walletCommon.EthereumMainnet:
return "https://eth-mainnet.g.alchemy.com", nil
case walletCommon.EthereumGoerli:
return "https://eth-goerli.g.alchemy.com", nil
case walletCommon.EthereumSepolia:
return "https://eth-sepolia.g.alchemy.com", nil
case walletCommon.OptimismMainnet:
return "https://opt-mainnet.g.alchemy.com", nil
case walletCommon.OptimismGoerli:
return "https://opt-goerli.g.alchemy.com", nil
case walletCommon.OptimismSepolia:
return "https://opt-sepolia.g.alchemy.com", nil
case walletCommon.ArbitrumMainnet:
return "https://arb-mainnet.g.alchemy.com", nil
case walletCommon.ArbitrumGoerli:
return "https://arb-goerli.g.alchemy.com", nil
case walletCommon.ArbitrumSepolia:
return "https://arb-sepolia.g.alchemy.com", nil
}
return "", thirdparty.ErrChainIDNotSupported
}
func (o *Client) ID() string {
return AlchemyID
}
func (o *Client) IsChainSupported(chainID walletCommon.ChainID) bool {
_, err := getBaseURL(chainID)
return err == nil
}
func (o *Client) IsConnected() bool {
return o.connectionStatus.IsConnected()
}
func getAPIKeySubpath(apiKey string) string {
if apiKey == "" {
return "demo"
}
return apiKey
}
func getNFTBaseURL(chainID walletCommon.ChainID, apiKey string) (string, error) {
baseURL, err := getBaseURL(chainID)
if err != nil {
return "", err
}
return fmt.Sprintf("%s/nft/v3/%s", baseURL, getAPIKeySubpath(apiKey)), nil
}
type Client struct {
thirdparty.CollectibleContractOwnershipProvider
client *http.Client
apiKeys map[uint64]string
connectionStatus *connection.Status
}
func NewClient(apiKeys map[uint64]string) *Client {
for _, chainID := range walletCommon.AllChainIDs() {
if apiKeys[uint64(chainID)] == "" {
log.Warn("Alchemy API key not available for", "chainID", chainID)
}
}
return &Client{
client: &http.Client{Timeout: time.Minute},
apiKeys: apiKeys,
connectionStatus: connection.NewStatus(),
}
}
func (o *Client) doQuery(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return o.doWithRetries(req)
}
func (o *Client) doPostWithJSON(ctx context.Context, url string, payload any) (*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)
}
func (o *Client) doWithRetries(req *http.Request) (*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()
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{
"contractAddress": {contractAddress.String()},
"withTokenBalances": {"true"},
}
baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
if err != nil {
return nil, err
}
for {
url := fmt.Sprintf("%s/getOwnersForContract?%s", baseURL, queryParams.Encode())
resp, err := o.doQuery(ctx, url)
if err != nil {
if ctx.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 alchemyOwnership CollectibleContractOwnership
err = json.Unmarshal(body, &alchemyOwnership)
if err != nil {
return nil, err
}
ownership.Owners = append(ownership.Owners, alchemyCollectibleOwnersToCommon(alchemyOwnership.Owners)...)
if alchemyOwnership.PageKey == "" {
break
}
queryParams["pageKey"] = []string{alchemyOwnership.PageKey}
}
return &ownership, nil
}
func (o *Client) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
queryParams := url.Values{}
return o.fetchOwnedAssets(ctx, chainID, owner, queryParams, cursor, limit)
}
func (o *Client) FetchAllAssetsByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
queryParams := url.Values{}
for _, contractAddress := range contractAddresses {
queryParams.Add("contractAddresses", contractAddress.String())
}
return o.fetchOwnedAssets(ctx, chainID, owner, queryParams, cursor, limit)
}
func (o *Client) fetchOwnedAssets(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, queryParams url.Values, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
assets := new(thirdparty.FullCollectibleDataContainer)
queryParams["owner"] = []string{owner.String()}
queryParams["withMetadata"] = []string{"true"}
if len(cursor) > 0 {
queryParams["pageKey"] = []string{cursor}
assets.PreviousCursor = cursor
}
assets.Provider = o.ID()
baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
if err != nil {
return nil, err
}
for {
url := fmt.Sprintf("%s/getNFTsForOwner?%s", baseURL, queryParams.Encode())
resp, err := o.doQuery(ctx, url)
if err != nil {
if ctx.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))
}
container := OwnedNFTList{}
err = json.Unmarshal(body, &container)
if err != nil {
return nil, err
}
assets.Items = append(assets.Items, alchemyToCollectiblesData(chainID, container.OwnedNFTs)...)
assets.NextCursor = container.PageKey
if len(assets.NextCursor) == 0 {
break
}
queryParams["cursor"] = []string{assets.NextCursor}
if limit != thirdparty.FetchNoLimit && len(assets.Items) >= limit {
break
}
}
return assets, nil
}
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([]TokenID, 0, len(pageIDs)),
}
for _, id := range pageIDs {
batchID := TokenID{
ContractAddress: id.ContractID.Address,
TokenID: id.TokenID,
}
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 := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
if err != nil {
return nil, err
}
url := fmt.Sprintf("%s/getNFTMetadataBatch", baseURL)
resp, err := o.doPostWithJSON(ctx, url, batchIDs)
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))
}
assets := NFTList{}
err = json.Unmarshal(body, &assets)
if err != nil {
return nil, err
}
ret := alchemyToCollectiblesData(chainID, assets.NFTs)
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 getContractAddressBatches(ids []thirdparty.ContractID) []BatchContractAddresses {
batches := make([]BatchContractAddresses, 0)
for startIdx := 0; startIdx < len(ids); startIdx += contractMetadataBatchLimit {
endIdx := startIdx + contractMetadataBatchLimit
if endIdx > len(ids) {
endIdx = len(ids)
}
pageIDs := ids[startIdx:endIdx]
batchIDs := BatchContractAddresses{
Addresses: make([]common.Address, 0, len(pageIDs)),
}
for _, id := range pageIDs {
batchIDs.Addresses = append(batchIDs.Addresses, id.Address)
}
batches = append(batches, batchIDs)
}
return batches
}
func (o *Client) fetchCollectionsDataByBatchContractAddresses(ctx context.Context, chainID walletCommon.ChainID, batchAddresses BatchContractAddresses) ([]thirdparty.CollectionData, error) {
baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
if err != nil {
return nil, err
}
url := fmt.Sprintf("%s/getContractMetadataBatch", baseURL)
resp, err := o.doPostWithJSON(ctx, url, batchAddresses)
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))
}
collections := ContractList{}
err = json.Unmarshal(body, &collections)
if err != nil {
return nil, err
}
ret := alchemyToCollectionsData(chainID, collections.Contracts)
return ret, nil
}
func (o *Client) FetchCollectionsDataByContractID(ctx context.Context, contractIDs []thirdparty.ContractID) ([]thirdparty.CollectionData, error) {
ret := make([]thirdparty.CollectionData, 0, len(contractIDs))
idsPerChainID := thirdparty.GroupContractIDsByChainID(contractIDs)
for chainID, ids := range idsPerChainID {
batches := getContractAddressBatches(ids)
for _, batch := range batches {
contractsData, err := o.fetchCollectionsDataByBatchContractAddresses(ctx, chainID, batch)
if err != nil {
return nil, err
}
ret = append(ret, contractsData...)
}
}
return ret, nil
}
@@ -0,0 +1,197 @@
// nolint: misspell
package alchemy
const collectionJSON = `{
"address": "0x06012c8cf97BEaD5deAe237070F9587f8E7A266d",
"name": "CryptoKitties",
"symbol": "CK",
"totalSupply": "2023564",
"tokenType": "ERC721",
"contractDeployer": "0xba52c75764d6F594735dc735Be7F1830CDf58dDf",
"deployedBlockNumber": 4605167,
"openSeaMetadata": {
"floorPrice": 0.003,
"collectionName": "CryptoKitties",
"collectionSlug": "cryptokitties",
"safelistRequestStatus": "verified",
"imageUrl": "https://i.seadn.io/gae/C272ZRW1RGGef9vKMePFSCeKc1Lw6U40wl9ofNVxzUxFdj84hH9xJRQNf-7wgs7W8qw8RWe-1ybKp-VKuU5D-tg?w=500&auto=format",
"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.",
"externalUrl": null,
"twitterUsername": "CryptoKitties",
"discordUrl": "https://discord.gg/cryptokitties",
"bannerImageUrl": "https://i.seadn.io/gcs/static/banners/cryptokitties-banner2.png?w=500&auto=format",
"lastIngestedAt": "2024-02-05T23:13:45.000Z"
}
}`
const ownedCollectiblesJSON = `{
"ownedNfts": [
{
"contract": {
"address": "0x2b1870752208935fDA32AB6A016C01a27877CF12",
"name": null,
"symbol": null,
"totalSupply": null,
"tokenType": "ERC1155",
"contractDeployer": "0x5Df2E003CEcb0ebf69cBd8F7Fbb6F44F690331F2",
"deployedBlockNumber": 8088320,
"openSeaMetadata": {
"floorPrice": 0,
"collectionName": "Unidentified contract - SkdH8ZQtyB",
"collectionSlug": "unidentified-contract-skdh8zqtyb",
"safelistRequestStatus": "not_requested",
"imageUrl": null,
"description": null,
"externalUrl": null,
"twitterUsername": null,
"discordUrl": null,
"bannerImageUrl": null,
"lastIngestedAt": "2024-01-28T06:17:57.000Z"
},
"isSpam": null,
"spamClassifications": []
},
"tokenId": "50659039041325838222074459099120411190538227963344971355684955900852972814336",
"tokenType": "ERC1155",
"name": "HODL",
"description": "The enemy king sent a single message, written on a parchment stained by blood.\n“You are advised to submit without further delay, for if I bring my army into your land, I will destroy your hodlings, slay your people, and burn your city to ashes.”\nHodlers of ENJ sent a single word as reply:\n“If.”\nThe battle that followed does not come around too often, a battle that began every legend told about the warriors that gained eternal glory. \nThe battle that followed seemed like a lost one from the very beginning. \nThe enemy army was revealed at dawn, illuminated by the rising Sun.The ground shook as countless hordes marched towards a small band of men armed with shields, spears and swords.\nThe hodlers were outnumbered, one thousand to one. \nFear, doubt and uncertainty did not reach their hearts and minds - for they were born for this. \nEach hodler was bred for warfare, instructed in bloodshed, groomed to become a poet of death. \nA philosopher of war, blood and glory. \nEach man was forged into an invincible soldier that had a single driving force during each battle.\nStand your ground - at all costs. \nAs the swarm of enemies approached, the king yelled, asking his men: \n“Hodlers! What is your profession?”\n“HODL! HODL! HODL! HODL!!! HODL!!!!!” they replied, hitting spears against their shields. \nAn endless stream of arrows fell from the heavens only moments later, blocking out the Sun so they could fight in the shade. They emerged from the darkness without even a single scratch, protected by their legendary Enjin shields. \nWave after wave, their enemies rushed towards their doom, as they were met with cold tips of thrusting spears and sharp edges of crimson swords.\nAgainst all odds, the wall of men and steel held against the never-ending, shilling swarm. \nWhat was left of the enemy army retreated, fleeing in absolute panic and indisputable terror.\nBathed in blood, the ENJ hodlers were victorious.\nTheir story will be told for thousands of years, immortalized with divine blocks and chains.\n* * *\n“HODL” was minted in 2018 for our amazing community of epic Enjin HODLers. We are extremely grateful for the trust you've put in us and the products we're making - and the mission we're trying to accomplish, and hope youll love this token of our appreciation. ",
"tokenUri": "https://cdn.enjin.io/mint/meta/70000000000001b2.json",
"image": {
"cachedUrl": "https://nft-cdn.alchemy.com/eth-mainnet/c5c93ffa8146ade7d3694c0f28463f0c",
"thumbnailUrl": "https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/c5c93ffa8146ade7d3694c0f28463f0c",
"pngUrl": "https://res.cloudinary.com/alchemyapi/image/upload/convert-png/eth-mainnet/c5c93ffa8146ade7d3694c0f28463f0c",
"contentType": "image/jpeg",
"size": 256926,
"originalUrl": "https://cdn.enjin.io/mint/image/70000000000001b2.jpg"
},
"raw": {
"tokenUri": "https://cdn.enjin.io/mint/meta/70000000000001b2.json",
"metadata": {
"name": "HODL",
"description": "The enemy king sent a single message, written on a parchment stained by blood.\n“You are advised to submit without further delay, for if I bring my army into your land, I will destroy your hodlings, slay your people, and burn your city to ashes.”\nHodlers of ENJ sent a single word as reply:\n“If.”\nThe battle that followed does not come around too often, a battle that began every legend told about the warriors that gained eternal glory. \nThe battle that followed seemed like a lost one from the very beginning. \nThe enemy army was revealed at dawn, illuminated by the rising Sun.The ground shook as countless hordes marched towards a small band of men armed with shields, spears and swords.\nThe hodlers were outnumbered, one thousand to one. \nFear, doubt and uncertainty did not reach their hearts and minds - for they were born for this. \nEach hodler was bred for warfare, instructed in bloodshed, groomed to become a poet of death. \nA philosopher of war, blood and glory. \nEach man was forged into an invincible soldier that had a single driving force during each battle.\nStand your ground - at all costs. \nAs the swarm of enemies approached, the king yelled, asking his men: \n“Hodlers! What is your profession?”\n“HODL! HODL! HODL! HODL!!! HODL!!!!!” they replied, hitting spears against their shields. \nAn endless stream of arrows fell from the heavens only moments later, blocking out the Sun so they could fight in the shade. They emerged from the darkness without even a single scratch, protected by their legendary Enjin shields. \nWave after wave, their enemies rushed towards their doom, as they were met with cold tips of thrusting spears and sharp edges of crimson swords.\nAgainst all odds, the wall of men and steel held against the never-ending, shilling swarm. \nWhat was left of the enemy army retreated, fleeing in absolute panic and indisputable terror.\nBathed in blood, the ENJ hodlers were victorious.\nTheir story will be told for thousands of years, immortalized with divine blocks and chains.\n* * *\n“HODL” was minted in 2018 for our amazing community of epic Enjin HODLers. We are extremely grateful for the trust you've put in us and the products we're making - and the mission we're trying to accomplish, and hope youll love this token of our appreciation. ",
"image": "https://cdn.enjin.io/mint/image/70000000000001b2.jpg"
},
"error": null
},
"collection": {
"name": "Unidentified contract - SkdH8ZQtyB",
"slug": "unidentified-contract-skdh8zqtyb",
"externalUrl": null,
"bannerImageUrl": null
},
"mint": {
"mintAddress": null,
"blockNumber": null,
"timestamp": null,
"transactionHash": null
},
"owners": null,
"timeLastUpdated": "2024-01-03T19:11:04.681Z",
"balance": "1",
"acquiredAt": {
"blockTimestamp": null,
"blockNumber": null
}
},
{
"contract": {
"address": "0x3f6B1585AfeFc56433C8d28AA89dbc77af59278f",
"name": "Simpson Punk",
"symbol": "SIMPUNK",
"totalSupply": "1789",
"tokenType": "ERC721",
"contractDeployer": "0xa74E02F671e00eeFbf4e13D9D89B397523653E67",
"deployedBlockNumber": 18607457,
"openSeaMetadata": {
"floorPrice": 0.00069,
"collectionName": "SimpsonPunks",
"collectionSlug": "simpsonpunkseth",
"safelistRequestStatus": "not_requested",
"imageUrl": "https://raw.seadn.io/files/e7765f13c4658f514d0efc008ae7f300.png",
"description": "1,789 SimpsonPunks entered the Blockchain????",
"externalUrl": null,
"twitterUsername": "SimpsonPunksETH",
"discordUrl": null,
"bannerImageUrl": null,
"lastIngestedAt": "2024-01-31T00:06:00.000Z"
},
"isSpam": null,
"spamClassifications": []
},
"tokenId": "900",
"tokenType": "ERC721",
"name": "#900",
"description": "5,555 SimpsonPunks entered the Ethereum Blockchain🍩",
"tokenUri": "https://alchemy.mypinata.cloud/ipfs/bafybeidqbmbglapk2bkffa4o2ws5jhxnhlbdeqh7k6tk62pukse3xhvv2e/900.json",
"image": {
"cachedUrl": "https://nft-cdn.alchemy.com/eth-mainnet/52accf48dc609088738b15808fe07e8c",
"thumbnailUrl": "https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/52accf48dc609088738b15808fe07e8c",
"pngUrl": "https://res.cloudinary.com/alchemyapi/image/upload/convert-png/eth-mainnet/52accf48dc609088738b15808fe07e8c",
"contentType": "image/png",
"size": 21206,
"originalUrl": "https://ipfs.io/ipfs/bafybeib2metombffkovfzbjhljpcmobb5wqazsbpenz2drysgm7und47ym/900.png"
},
"raw": {
"tokenUri": "ipfs://bafybeidqbmbglapk2bkffa4o2ws5jhxnhlbdeqh7k6tk62pukse3xhvv2e/900.json",
"metadata": {
"date": 1700514890266,
"image": "ipfs://bafybeib2metombffkovfzbjhljpcmobb5wqazsbpenz2drysgm7und47ym/900.png",
"name": "#900",
"description": "5,555 SimpsonPunks entered the Ethereum Blockchain🍩",
"edition": 900,
"attributes": [
{
"value": "Background",
"trait_type": "layers"
},
{
"value": "Monkey",
"trait_type": "Face"
},
{
"value": "Sweatband Blue",
"trait_type": "Head"
},
{
"value": "Thin Full",
"trait_type": "Facial Hair"
},
{
"value": "Burger",
"trait_type": "Mouth"
}
],
"imageHash": "c1055d7f183405a5ec934bf6d83ac653740795cbf40031e3b34e957a352b471c",
"compiler": "HashLips Art Engine - NFTChef fork"
},
"error": null
},
"collection": {
"name": "SimpsonPunks",
"slug": "simpsonpunkseth",
"externalUrl": null,
"bannerImageUrl": null
},
"mint": {
"mintAddress": null,
"blockNumber": null,
"timestamp": null,
"transactionHash": null
},
"owners": null,
"timeLastUpdated": "2024-01-03T21:02:33.333Z",
"balance": "1",
"acquiredAt": {
"blockTimestamp": null,
"blockNumber": null
}
}
],
"totalCount": 53,
"validAt": {
"blockNumber": 19169748,
"blockHash": "0x95badbc2af9d0fa4ecfb614e6f24910e027b3e0a5c6fa4c32521ff5bc7754693",
"blockTimestamp": "2024-02-06T14:20:11Z"
},
"pageKey": "MHgyYjE4NzA3NTIyMDg5MzVmZGEzMmFiNmEwMTZjMDFhMjc4NzdjZjEyOjB4NzgwMDAwMDAwMDAwMDIyZTAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDpmYWxzZQ=="
}`
@@ -0,0 +1,249 @@
package alchemy
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 AlchemyID = "alchemy"
type TokenBalance struct {
TokenID *bigint.BigInt `json:"tokenId"`
Balance *bigint.BigInt `json:"balance"`
}
type CollectibleOwner struct {
OwnerAddress common.Address `json:"ownerAddress"`
TokenBalances []TokenBalance `json:"tokenBalances"`
}
type CollectibleContractOwnership struct {
Owners []CollectibleOwner `json:"owners"`
PageKey string `json:"pageKey"`
}
func alchemyCollectibleOwnersToCommon(alchemyOwners []CollectibleOwner) []thirdparty.CollectibleOwner {
owners := make([]thirdparty.CollectibleOwner, 0, len(alchemyOwners))
for _, alchemyOwner := range alchemyOwners {
balances := make([]thirdparty.TokenBalance, 0, len(alchemyOwner.TokenBalances))
for _, alchemyBalance := range alchemyOwner.TokenBalances {
balances = append(balances, thirdparty.TokenBalance{
TokenID: &bigint.BigInt{Int: alchemyBalance.TokenID.Int},
Balance: alchemyBalance.Balance,
})
}
owner := thirdparty.CollectibleOwner{
OwnerAddress: alchemyOwner.OwnerAddress,
TokenBalances: balances,
}
owners = append(owners, owner)
}
return owners
}
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 Attribute struct {
TraitType string `json:"trait_type"`
Value AttributeValue `json:"value"`
}
type RawMetadata struct {
Attributes []Attribute `json:"attributes"`
}
type RawFull struct {
RawMetadata RawMetadata `json:"metadata"`
}
type Raw struct {
RawMetadata interface{} `json:"metadata"`
}
func (r *Raw) UnmarshalJSON(b []byte) error {
raw := RawFull{
RawMetadata{
Attributes: make([]Attribute, 0),
},
}
// Field structure is not known in advance
_ = json.Unmarshal(b, &raw)
r.RawMetadata = raw.RawMetadata
return nil
}
type OpenSeaMetadata struct {
ImageURL string `json:"imageUrl"`
}
type Contract struct {
Address common.Address `json:"address"`
Name string `json:"name"`
Symbol string `json:"symbol"`
TokenType string `json:"tokenType"`
OpenSeaMetadata OpenSeaMetadata `json:"openseaMetadata"`
}
type ContractList struct {
Contracts []Contract `json:"contracts"`
}
type Image struct {
ImageURL string `json:"pngUrl"`
CachedAnimationURL string `json:"cachedUrl"`
OriginalAnimationURL string `json:"originalUrl"`
}
type Asset struct {
Contract Contract `json:"contract"`
TokenID *bigint.BigInt `json:"tokenId"`
Name string `json:"name"`
Description string `json:"description"`
Image Image `json:"image"`
Raw Raw `json:"raw"`
TokenURI string `json:"tokenUri"`
}
type OwnedNFTList struct {
OwnedNFTs []Asset `json:"ownedNfts"`
TotalCount *bigint.BigInt `json:"totalCount"`
PageKey string `json:"pageKey"`
}
type NFTList struct {
NFTs []Asset `json:"nfts"`
}
type BatchContractAddresses struct {
Addresses []common.Address `json:"contractAddresses"`
}
type BatchTokenIDs struct {
IDs []TokenID `json:"tokens"`
}
type TokenID struct {
ContractAddress common.Address `json:"contractAddress"`
TokenID *bigint.BigInt `json:"tokenId"`
}
func alchemyToCollectibleTraits(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: strings.Replace(orig.TraitType, "_", " ", 1),
Value: caser.String(string(orig.Value)),
}
ret = append(ret, dest)
}
return ret
}
func alchemyToContractType(tokenType string) walletCommon.ContractType {
switch tokenType {
case "ERC721":
return walletCommon.ContractTypeERC721
case "ERC1155":
return walletCommon.ContractTypeERC1155
default:
return walletCommon.ContractTypeUnknown
}
}
func (c *Contract) toCollectionData(id thirdparty.ContractID) thirdparty.CollectionData {
ret := thirdparty.CollectionData{
ID: id,
ContractType: alchemyToContractType(c.TokenType),
Provider: AlchemyID,
Name: c.Name,
ImageURL: c.OpenSeaMetadata.ImageURL,
Traits: make(map[string]thirdparty.CollectionTrait, 0),
}
return ret
}
func (c *Asset) toCollectiblesData(id thirdparty.CollectibleUniqueID) thirdparty.CollectibleData {
rawMetadata := c.Raw.RawMetadata.(RawMetadata)
return thirdparty.CollectibleData{
ID: id,
ContractType: alchemyToContractType(c.Contract.TokenType),
Provider: AlchemyID,
Name: c.Name,
Description: c.Description,
ImageURL: c.Image.ImageURL,
AnimationURL: c.Image.CachedAnimationURL,
Traits: alchemyToCollectibleTraits(rawMetadata.Attributes),
TokenURI: c.TokenURI,
}
}
func (c *Asset) toCommon(id thirdparty.CollectibleUniqueID) thirdparty.FullCollectibleData {
contractData := c.Contract.toCollectionData(id.ContractID)
return thirdparty.FullCollectibleData{
CollectibleData: c.toCollectiblesData(id),
CollectionData: &contractData,
}
}
func alchemyToCollectiblesData(chainID walletCommon.ChainID, l []Asset) []thirdparty.FullCollectibleData {
ret := make([]thirdparty.FullCollectibleData, 0, len(l))
for _, asset := range l {
id := thirdparty.CollectibleUniqueID{
ContractID: thirdparty.ContractID{
ChainID: chainID,
Address: asset.Contract.Address,
},
TokenID: asset.TokenID,
}
item := asset.toCommon(id)
ret = append(ret, item)
}
return ret
}
func alchemyToCollectionsData(chainID walletCommon.ChainID, l []Contract) []thirdparty.CollectionData {
ret := make([]thirdparty.CollectionData, 0, len(l))
for _, contract := range l {
id := thirdparty.ContractID{
ChainID: chainID,
Address: contract.Address,
}
item := contract.toCollectionData(id)
ret = append(ret, item)
}
return ret
}
@@ -0,0 +1,308 @@
package coingecko
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"sync"
"time"
"github.com/status-im/status-go/services/wallet/thirdparty"
"github.com/status-im/status-go/services/wallet/thirdparty/utils"
)
var coinGeckoMapping = map[string]string{
"STT": "status",
"SNT": "status",
"ETH": "ethereum",
"AST": "airswap",
"AMB": "",
"ABT": "arcblock",
"ATM": "",
"BNB": "binancecoin",
"BLT": "bloom",
"CDT": "",
"COMP": "compound-coin",
"EDG": "edgeless",
"ELF": "",
"ENG": "enigma",
"EOS": "eos",
"GEN": "daostack",
"MANA": "decentraland-wormhole",
"LEND": "ethlend",
"LRC": "loopring",
"MET": "metronome",
"POLY": "polymath",
"PPT": "populous",
"SAN": "santiment-network-token",
"DNT": "district0x",
"SPN": "sapien",
"USDS": "stableusd",
"STX": "stox",
"SUB": "substratum",
"PAY": "tenx",
"GRT": "the-graph",
"TNT": "tierion",
"TRX": "tron",
"TGT": "",
"RARE": "superrare",
"UNI": "uniswap",
"USDC": "usd-coin",
"USDP": "paxos-standard",
"VRS": "",
"TIME": "",
}
const baseURL = "https://api.coingecko.com/api/v3/"
type HistoricalPriceContainer struct {
Prices [][]float64 `json:"prices"`
}
type GeckoMarketValues struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
Name string `json:"name"`
MarketCap float64 `json:"market_cap"`
High24h float64 `json:"high_24h"`
Low24h float64 `json:"low_24h"`
PriceChange24h float64 `json:"price_change_24h"`
PriceChangePercentage24h float64 `json:"price_change_percentage_24h"`
PriceChangePercentage1hInCurrency float64 `json:"price_change_percentage_1h_in_currency"`
}
type GeckoToken struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
Name string `json:"name"`
}
type Client struct {
client *http.Client
tokens map[string]GeckoToken
tokensURL string
fetchTokensMutex sync.Mutex
}
func NewClient() *Client {
return &Client{client: &http.Client{Timeout: time.Minute}, tokens: make(map[string]GeckoToken), tokensURL: fmt.Sprintf("%scoins/list", baseURL)}
}
func (c *Client) DoQuery(url string) (*http.Response, error) {
resp, err := c.client.Get(url)
if err != nil {
return nil, err
}
return resp, nil
}
func mapTokensToSymbols(tokens []GeckoToken, tokenMap map[string]GeckoToken) {
for _, token := range tokens {
if id, ok := coinGeckoMapping[strings.ToUpper(token.Symbol)]; ok {
if id != token.ID {
continue
}
}
tokenMap[strings.ToUpper(token.Symbol)] = token
}
}
func (c *Client) getTokens() (map[string]GeckoToken, error) {
c.fetchTokensMutex.Lock()
defer c.fetchTokensMutex.Unlock()
if len(c.tokens) > 0 {
return c.tokens, nil
}
resp, err := c.DoQuery(c.tokensURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var tokens []GeckoToken
err = json.Unmarshal(body, &tokens)
if err != nil {
return nil, err
}
mapTokensToSymbols(tokens, c.tokens)
return c.tokens, nil
}
func (c *Client) mapSymbolsToIds(symbols []string) ([]string, error) {
tokens, err := c.getTokens()
if err != nil {
return nil, err
}
ids := make([]string, 0)
for _, symbol := range utils.RenameSymbols(symbols) {
if token, ok := tokens[symbol]; ok {
ids = append(ids, token.ID)
}
}
return ids, nil
}
func (c *Client) getIDFromSymbol(symbol string) (string, error) {
tokens, err := c.getTokens()
if err != nil {
return "", err
}
return tokens[strings.ToUpper(symbol)].ID, nil
}
func (c *Client) FetchPrices(symbols []string, currencies []string) (map[string]map[string]float64, error) {
ids, err := c.mapSymbolsToIds(symbols)
if err != nil {
return nil, err
}
url := fmt.Sprintf("%ssimple/price?ids=%s&vs_currencies=%s", baseURL, strings.Join(ids, ","), strings.Join(currencies, ","))
resp, err := c.DoQuery(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
prices := make(map[string]map[string]float64)
err = json.Unmarshal(body, &prices)
if err != nil {
return nil, err
}
result := make(map[string]map[string]float64)
for _, symbol := range symbols {
result[symbol] = map[string]float64{}
id, err := c.getIDFromSymbol(utils.GetRealSymbol(symbol))
if err != nil {
return nil, err
}
for _, currency := range currencies {
result[symbol][currency] = prices[id][strings.ToLower(currency)]
}
}
return result, nil
}
func (c *Client) FetchTokenDetails(symbols []string) (map[string]thirdparty.TokenDetails, error) {
tokens, err := c.getTokens()
if err != nil {
return nil, err
}
result := make(map[string]thirdparty.TokenDetails)
for _, symbol := range symbols {
if value, ok := tokens[utils.GetRealSymbol(symbol)]; ok {
result[symbol] = thirdparty.TokenDetails{
ID: value.ID,
Name: value.Name,
Symbol: symbol,
}
}
}
return result, nil
}
func (c *Client) FetchTokenMarketValues(symbols []string, currency string) (map[string]thirdparty.TokenMarketValues, error) {
ids, err := c.mapSymbolsToIds(symbols)
if err != nil {
return nil, err
}
url := fmt.Sprintf("%scoins/markets?ids=%s&vs_currency=%s&order=market_cap_desc&per_page=250&page=1&sparkline=false&price_change_percentage=%s", baseURL, strings.Join(ids, ","), currency, "1h%2C24h")
resp, err := c.DoQuery(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var marketValues []GeckoMarketValues
err = json.Unmarshal(body, &marketValues)
if err != nil {
return nil, err
}
result := make(map[string]thirdparty.TokenMarketValues)
for _, symbol := range symbols {
id, err := c.getIDFromSymbol(utils.GetRealSymbol(symbol))
if err != nil {
return nil, err
}
for _, marketValue := range marketValues {
if id != marketValue.ID {
continue
}
result[symbol] = thirdparty.TokenMarketValues{
MKTCAP: marketValue.MarketCap,
HIGHDAY: marketValue.High24h,
LOWDAY: marketValue.Low24h,
CHANGEPCTHOUR: marketValue.PriceChangePercentage1hInCurrency,
CHANGEPCTDAY: marketValue.PriceChangePercentage24h,
CHANGEPCT24HOUR: marketValue.PriceChangePercentage24h,
CHANGE24HOUR: marketValue.PriceChange24h,
}
}
}
return result, nil
}
func (c *Client) FetchHistoricalHourlyPrices(symbol string, currency string, limit int, aggregate int) ([]thirdparty.HistoricalPrice, error) {
return []thirdparty.HistoricalPrice{}, nil
}
func (c *Client) FetchHistoricalDailyPrices(symbol string, currency string, limit int, allData bool, aggregate int) ([]thirdparty.HistoricalPrice, error) {
id, err := c.getIDFromSymbol(utils.GetRealSymbol(symbol))
if err != nil {
return nil, err
}
url := fmt.Sprintf("%scoins/%s/market_chart?vs_currency=%s&days=30", baseURL, id, currency)
resp, err := c.DoQuery(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var container HistoricalPriceContainer
err = json.Unmarshal(body, &container)
if err != nil {
return nil, err
}
result := make([]thirdparty.HistoricalPrice, 0)
for _, price := range container.Prices {
result = append(result, thirdparty.HistoricalPrice{
Timestamp: int64(price[0]),
Value: price[1],
})
}
return result, nil
}
@@ -0,0 +1,230 @@
package thirdparty
import (
"context"
"database/sql"
"errors"
"fmt"
"math/big"
"github.com/ethereum/go-ethereum/common"
"github.com/status-im/status-go/protocol/communities/token"
"github.com/status-im/status-go/services/wallet/bigint"
w_common "github.com/status-im/status-go/services/wallet/common"
)
var (
ErrChainIDNotSupported = errors.New("chainID not supported")
ErrEndpointNotSupported = errors.New("endpoint not supported")
)
const FetchNoLimit = 0
const FetchFromStartCursor = ""
const FetchFromAnyProvider = ""
type CollectibleProvider interface {
ID() string
IsChainSupported(chainID w_common.ChainID) bool
IsConnected() bool
}
type ContractID struct {
ChainID w_common.ChainID `json:"chainID"`
Address common.Address `json:"address"`
}
func (k *ContractID) HashKey() string {
return fmt.Sprintf("%d+%s", k.ChainID, k.Address.String())
}
type CollectibleUniqueID struct {
ContractID ContractID `json:"contractID"`
TokenID *bigint.BigInt `json:"tokenID"`
}
func (k *CollectibleUniqueID) HashKey() string {
return fmt.Sprintf("%s+%s", k.ContractID.HashKey(), k.TokenID.String())
}
func (k *CollectibleUniqueID) Same(other *CollectibleUniqueID) bool {
return k.ContractID.ChainID == other.ContractID.ChainID && k.ContractID.Address == other.ContractID.Address && k.TokenID.Cmp(other.TokenID.Int) == 0
}
func RowsToCollectibles(rows *sql.Rows) ([]CollectibleUniqueID, error) {
var ids []CollectibleUniqueID
for rows.Next() {
id := CollectibleUniqueID{
TokenID: &bigint.BigInt{Int: big.NewInt(0)},
}
err := rows.Scan(
&id.ContractID.ChainID,
&id.ContractID.Address,
(*bigint.SQLBigIntBytes)(id.TokenID.Int),
)
if err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, nil
}
func GroupCollectibleUIDsByChainID(uids []CollectibleUniqueID) map[w_common.ChainID][]CollectibleUniqueID {
ret := make(map[w_common.ChainID][]CollectibleUniqueID)
for _, uid := range uids {
if _, ok := ret[uid.ContractID.ChainID]; !ok {
ret[uid.ContractID.ChainID] = make([]CollectibleUniqueID, 0, len(uids))
}
ret[uid.ContractID.ChainID] = append(ret[uid.ContractID.ChainID], uid)
}
return ret
}
func GroupContractIDsByChainID(ids []ContractID) map[w_common.ChainID][]ContractID {
ret := make(map[w_common.ChainID][]ContractID)
for _, id := range ids {
if _, ok := ret[id.ChainID]; !ok {
ret[id.ChainID] = make([]ContractID, 0, len(ids))
}
ret[id.ChainID] = append(ret[id.ChainID], id)
}
return ret
}
type CollectionTrait struct {
Min float64 `json:"min"`
Max float64 `json:"max"`
}
// Collection info
type CollectionData struct {
ID ContractID `json:"id"`
ContractType w_common.ContractType `json:"contract_type"`
CommunityID string `json:"community_id"`
Provider string `json:"provider"`
Name string `json:"name"`
Slug string `json:"slug"`
ImageURL string `json:"image_url"`
ImagePayload []byte
Traits map[string]CollectionTrait `json:"traits"`
}
type CollectibleTrait struct {
TraitType string `json:"trait_type"`
Value string `json:"value"`
DisplayType string `json:"display_type"`
MaxValue string `json:"max_value"`
}
// Collectible info
type CollectibleData struct {
ID CollectibleUniqueID `json:"id"`
ContractType w_common.ContractType `json:"contract_type"`
CommunityID string `json:"community_id"`
Provider string `json:"provider"`
Name string `json:"name"`
Description string `json:"description"`
Permalink string `json:"permalink"`
ImageURL string `json:"image_url"`
ImagePayload []byte
AnimationURL string `json:"animation_url"`
AnimationMediaType string `json:"animation_media_type"`
Traits []CollectibleTrait `json:"traits"`
BackgroundColor string `json:"background_color"`
TokenURI string `json:"token_uri"`
}
// Community-related collectible info. Present only for collectibles minted in a community.
type CollectibleCommunityInfo struct {
PrivilegesLevel token.PrivilegesLevel `json:"privileges_level"`
}
// Combined Collection+Collectible info returned by the CollectibleProvider
// Some providers may not return the CollectionData in the same API call, so it's optional
type FullCollectibleData struct {
CollectibleData CollectibleData
CollectionData *CollectionData
CommunityInfo *CommunityInfo
CollectibleCommunityInfo *CollectibleCommunityInfo
Ownership []AccountBalance
}
type CollectiblesContainer[T any] struct {
Items []T
NextCursor string
PreviousCursor string
Provider string
}
type CollectibleOwnershipContainer CollectiblesContainer[CollectibleUniqueID]
type CollectionDataContainer CollectiblesContainer[CollectionData]
type CollectibleDataContainer CollectiblesContainer[CollectibleData]
type FullCollectibleDataContainer CollectiblesContainer[FullCollectibleData]
// Tried to find a way to make this generic, but couldn't, so the code below is duplicated somewhere else
func collectibleItemsToIDs(items []FullCollectibleData) []CollectibleUniqueID {
ret := make([]CollectibleUniqueID, 0, len(items))
for _, item := range items {
ret = append(ret, item.CollectibleData.ID)
}
return ret
}
func (c *FullCollectibleDataContainer) ToOwnershipContainer() CollectibleOwnershipContainer {
return CollectibleOwnershipContainer{
Items: collectibleItemsToIDs(c.Items),
NextCursor: c.NextCursor,
PreviousCursor: c.PreviousCursor,
Provider: c.Provider,
}
}
type TokenBalance struct {
TokenID *bigint.BigInt `json:"tokenId"`
Balance *bigint.BigInt `json:"balance"`
}
type TokenBalancesPerContractAddress = map[common.Address][]TokenBalance
type CollectibleOwner struct {
OwnerAddress common.Address `json:"ownerAddress"`
TokenBalances []TokenBalance `json:"tokenBalances"`
}
type CollectibleContractOwnership struct {
ContractAddress common.Address `json:"contractAddress"`
Owners []CollectibleOwner `json:"owners"`
}
type AccountBalance struct {
Address common.Address `json:"address"`
Balance *bigint.BigInt `json:"balance"`
TxTimestamp int64 `json:"txTimestamp"`
}
type CollectibleContractOwnershipProvider interface {
CollectibleProvider
FetchCollectibleOwnersByContractAddress(ctx context.Context, chainID w_common.ChainID, contractAddress common.Address) (*CollectibleContractOwnership, error)
}
type CollectibleAccountOwnershipProvider interface {
CollectibleProvider
FetchAllAssetsByOwner(ctx context.Context, chainID w_common.ChainID, owner common.Address, cursor string, limit int) (*FullCollectibleDataContainer, error)
FetchAllAssetsByOwnerAndContractAddress(ctx context.Context, chainID w_common.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*FullCollectibleDataContainer, error)
}
type CollectibleDataProvider interface {
CollectibleProvider
FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []CollectibleUniqueID) ([]FullCollectibleData, error)
}
type CollectionDataProvider interface {
CollectibleProvider
FetchCollectionsDataByContractID(ctx context.Context, ids []ContractID) ([]CollectionData, error)
}
@@ -0,0 +1,17 @@
package thirdparty
// Community-related info used by the wallet, cached in the wallet db.
type CommunityInfo struct {
CommunityName string `json:"community_name"`
CommunityColor string `json:"community_color"`
CommunityImage string `json:"community_image"`
CommunityImagePayload []byte
}
type CommunityInfoProvider interface {
FetchCommunityInfo(communityID string) (*CommunityInfo, error)
// Collectible-related methods
GetCommunityID(tokenURI string) string
FillCollectibleMetadata(collectible *FullCollectibleData) error
}
@@ -0,0 +1,196 @@
package cryptocompare
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"time"
"github.com/status-im/status-go/services/wallet/thirdparty"
"github.com/status-im/status-go/services/wallet/thirdparty/utils"
)
const baseURL = "https://min-api.cryptocompare.com"
type HistoricalPricesContainer struct {
Aggregated bool `json:"Aggregated"`
TimeFrom int64 `json:"TimeFrom"`
TimeTo int64 `json:"TimeTo"`
HistoricalData []thirdparty.HistoricalPrice `json:"Data"`
}
type HistoricalPricesData struct {
Data HistoricalPricesContainer `json:"Data"`
}
type TokenDetailsContainer struct {
Data map[string]thirdparty.TokenDetails `json:"Data"`
}
type MarketValuesContainer struct {
Raw map[string]map[string]thirdparty.TokenMarketValues `json:"Raw"`
}
type Client struct {
client *http.Client
}
func NewClient() *Client {
return &Client{client: &http.Client{Timeout: time.Minute}}
}
func (c *Client) DoQuery(url string) (*http.Response, error) {
resp, err := c.client.Get(url)
if err != nil {
return nil, err
}
return resp, nil
}
func (c *Client) FetchPrices(symbols []string, currencies []string) (map[string]map[string]float64, error) {
chunks := utils.ChunkSymbols(symbols, 60)
result := make(map[string]map[string]float64)
realCurrencies := utils.RenameSymbols(currencies)
for _, smbls := range chunks {
realSymbols := utils.RenameSymbols(smbls)
url := fmt.Sprintf("%s/data/pricemulti?fsyms=%s&tsyms=%s&extraParams=Status.im", baseURL, strings.Join(realSymbols, ","), strings.Join(realCurrencies, ","))
resp, err := c.DoQuery(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
prices := make(map[string]map[string]float64)
err = json.Unmarshal(body, &prices)
if err != nil {
return nil, err
}
for _, symbol := range smbls {
result[symbol] = map[string]float64{}
for _, currency := range currencies {
result[symbol][currency] = prices[utils.GetRealSymbol(symbol)][utils.GetRealSymbol(currency)]
}
}
}
return result, nil
}
func (c *Client) FetchTokenDetails(symbols []string) (map[string]thirdparty.TokenDetails, error) {
url := fmt.Sprintf("%s/data/all/coinlist", baseURL)
resp, err := c.DoQuery(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
container := TokenDetailsContainer{}
err = json.Unmarshal(body, &container)
if err != nil {
return nil, err
}
tokenDetails := make(map[string]thirdparty.TokenDetails)
for _, symbol := range symbols {
tokenDetails[symbol] = container.Data[utils.GetRealSymbol(symbol)]
}
return tokenDetails, nil
}
func (c *Client) FetchTokenMarketValues(symbols []string, currency string) (map[string]thirdparty.TokenMarketValues, error) {
chunks := utils.ChunkSymbols(symbols)
realCurrency := utils.GetRealSymbol(currency)
item := map[string]thirdparty.TokenMarketValues{}
for _, smbls := range chunks {
realSymbols := utils.RenameSymbols(smbls)
url := fmt.Sprintf("%s/data/pricemultifull?fsyms=%s&tsyms=%s&extraParams=Status.im", baseURL, strings.Join(realSymbols, ","), realCurrency)
resp, err := c.DoQuery(url)
if err != nil {
return item, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return item, err
}
container := MarketValuesContainer{}
err = json.Unmarshal(body, &container)
if err != nil {
return item, err
}
for _, symbol := range smbls {
item[symbol] = container.Raw[utils.GetRealSymbol(symbol)][utils.GetRealSymbol(currency)]
}
}
return item, nil
}
func (c *Client) FetchHistoricalHourlyPrices(symbol string, currency string, limit int, aggregate int) ([]thirdparty.HistoricalPrice, error) {
item := []thirdparty.HistoricalPrice{}
url := fmt.Sprintf("%s/data/v2/histohour?fsym=%s&tsym=%s&aggregate=%d&limit=%d&extraParams=Status.im", baseURL, utils.GetRealSymbol(symbol), currency, aggregate, limit)
resp, err := c.DoQuery(url)
if err != nil {
return item, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return item, err
}
container := HistoricalPricesData{}
err = json.Unmarshal(body, &container)
if err != nil {
return item, err
}
item = container.Data.HistoricalData
return item, nil
}
func (c *Client) FetchHistoricalDailyPrices(symbol string, currency string, limit int, allData bool, aggregate int) ([]thirdparty.HistoricalPrice, error) {
item := []thirdparty.HistoricalPrice{}
url := fmt.Sprintf("%s/data/v2/histoday?fsym=%s&tsym=%s&aggregate=%d&limit=%d&allData=%v&extraParams=Status.im", baseURL, utils.GetRealSymbol(symbol), currency, aggregate, limit, allData)
resp, err := c.DoQuery(url)
if err != nil {
return item, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return item, err
}
container := HistoricalPricesData{}
err = json.Unmarshal(body, &container)
if err != nil {
return item, err
}
item = container.Data.HistoricalData
return item, nil
}
@@ -0,0 +1,118 @@
package fourbyte
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"regexp"
"sort"
"strings"
"time"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/status-im/status-go/services/wallet/thirdparty"
)
type Signature struct {
ID int `json:"id"`
Text string `json:"text_signature"`
}
type ByID []Signature
func (s ByID) Len() int { return len(s) }
func (s ByID) Less(i, j int) bool { return s[i].ID > s[j].ID }
func (s ByID) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
type SignatureList struct {
Count int `json:"count"`
Results []Signature `json:"results"`
}
type Client struct {
Client *http.Client
URL string
}
func NewClient() *Client {
return &Client{Client: &http.Client{Timeout: time.Minute}, URL: "https://www.4byte.directory"}
}
func (c *Client) DoQuery(url string) (*http.Response, error) {
resp, err := c.Client.Get(url)
if err != nil {
return nil, err
}
return resp, nil
}
func (c *Client) Run(data string) (*thirdparty.DataParsed, error) {
if len(data) < 10 || !strings.HasPrefix(data, "0x") {
return nil, errors.New("input is badly formatted")
}
methodSigData := data[2:10]
url := fmt.Sprintf("%s/api/v1/signatures/?hex_signature=%s", c.URL, methodSigData)
resp, err := c.DoQuery(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var signatures SignatureList
err = json.Unmarshal(body, &signatures)
if err != nil {
return nil, err
}
if signatures.Count == 0 {
return nil, err
}
rgx := regexp.MustCompile(`\((.*?)\)`)
results := signatures.Results
sort.Sort(ByID(results))
for _, signature := range results {
id := fmt.Sprintf("0x%x", signature.ID)
name := strings.Split(signature.Text, "(")[0]
rs := rgx.FindStringSubmatch(signature.Text)
inputsMapString := make(map[string]string)
if len(rs[1]) > 0 {
inputs := make([]string, 0)
rawInputs := strings.Split(rs[1], ",")
for index, typ := range rawInputs {
if index == len(rawInputs)-1 && typ == "bytes" {
continue
}
inputs = append(inputs, fmt.Sprintf("{\"name\":\"%d\",\"type\":\"%s\"}", index, typ))
}
functionABI := fmt.Sprintf("[{\"constant\":true,\"inputs\":[%s],\"payable\":false,\"stateMutability\":\"view\",\"type\":\"function\", \"name\": \"%s\"}], ", strings.Join(inputs, ","), name)
contractABI, err := abi.JSON(strings.NewReader(functionABI))
if err != nil {
continue
}
method := contractABI.Methods[name]
inputsMap := make(map[string]interface{})
if err := method.Inputs.UnpackIntoMap(inputsMap, []byte(data[10:])); err != nil {
continue
}
for key, value := range inputsMap {
inputsMapString[key] = fmt.Sprintf("%v", value)
}
}
return &thirdparty.DataParsed{
Name: name,
ID: id,
Signature: signature.Text,
Inputs: inputsMapString,
}, nil
}
return nil, errors.New("couldn't find a corresponding signature")
}
@@ -0,0 +1,105 @@
package fourbytegithub
import (
"errors"
"fmt"
"io/ioutil"
"net/http"
"regexp"
"strings"
"time"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/status-im/status-go/services/wallet/thirdparty"
)
type Signature struct {
ID int `json:"id"`
Text string `json:"text_signature"`
}
type ByID []Signature
func (s ByID) Len() int { return len(s) }
func (s ByID) Less(i, j int) bool { return s[i].ID > s[j].ID }
func (s ByID) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
type SignatureList struct {
Count int `json:"count"`
Results []Signature `json:"results"`
}
type Client struct {
Client *http.Client
URL string
}
func NewClient() *Client {
return &Client{Client: &http.Client{Timeout: time.Minute}, URL: "https://raw.githubusercontent.com"}
}
func (c *Client) DoQuery(url string) (*http.Response, error) {
resp, err := c.Client.Get(url)
if err != nil {
return nil, err
}
return resp, nil
}
func (c *Client) Run(data string) (*thirdparty.DataParsed, error) {
if len(data) < 10 || !strings.HasPrefix(data, "0x") {
return nil, errors.New("input is badly formatted")
}
methodSigData := data[2:10]
url := fmt.Sprintf("%s/ethereum-lists/4bytes/master/signatures/%s", c.URL, methodSigData)
resp, err := c.DoQuery(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
signature := string(body)
rgx := regexp.MustCompile(`\((.*?)\)`)
id := fmt.Sprintf("0x%s", methodSigData)
name := strings.Split(signature, "(")[0]
rs := rgx.FindStringSubmatch(signature)
inputsMapString := make(map[string]string)
if len(rs[1]) > 0 {
inputs := make([]string, 0)
rawInputs := strings.Split(rs[1], ",")
for index, typ := range rawInputs {
if index == len(rawInputs)-1 && typ == "bytes" {
continue
}
inputs = append(inputs, fmt.Sprintf("{\"name\":\"%d\",\"type\":\"%s\"}", index, typ))
}
functionABI := fmt.Sprintf("[{\"constant\":true,\"inputs\":[%s],\"payable\":false,\"stateMutability\":\"view\",\"type\":\"function\", \"name\": \"%s\"}], ", strings.Join(inputs, ","), name)
contractABI, err := abi.JSON(strings.NewReader(functionABI))
if err != nil {
return nil, err
}
method := contractABI.Methods[name]
inputsMap := make(map[string]interface{})
if err := method.Inputs.UnpackIntoMap(inputsMap, []byte(data[10:])); err != nil {
return nil, err
}
for key, value := range inputsMap {
inputsMapString[key] = fmt.Sprintf("%v", value)
}
}
return &thirdparty.DataParsed{
Name: name,
ID: id,
Signature: signature,
Inputs: inputsMapString,
}, nil
}
@@ -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
}
@@ -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))
}
@@ -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
}
@@ -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
}
@@ -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
}
]
}`
@@ -0,0 +1,359 @@
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 raribleToContractType(contractType string) walletCommon.ContractType {
switch contractType {
case "CRYPTO_PUNKS", "ERC721":
return walletCommon.ContractTypeERC721
case "ERC1155":
return walletCommon.ContractTypeERC1155
default:
return walletCommon.ContractTypeUnknown
}
}
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"`
ContractType string `json:"type"`
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,
ContractType: raribleToContractType(c.ContractType),
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,
ContractType: walletCommon.ContractTypeUnknown, // Rarible doesn't provide the contract type with the collectible
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,
}
}
@@ -0,0 +1,174 @@
package thirdparty
import (
"fmt"
"math/big"
"math/rand"
"github.com/ethereum/go-ethereum/common"
"github.com/status-im/status-go/protocol/communities/token"
"github.com/status-im/status-go/services/wallet/bigint"
w_common "github.com/status-im/status-go/services/wallet/common"
)
func generateContractType(seed int) w_common.ContractType {
if seed%2 == 0 {
return w_common.ContractTypeERC721
}
return w_common.ContractTypeERC1155
}
func GenerateTestCollectiblesData(count int) (result []CollectibleData) {
base := rand.Intn(100) // nolint: gosec
result = make([]CollectibleData, 0, count)
for i := base; i < count+base; i++ {
bigI := big.NewInt(int64(i))
newCollectible := CollectibleData{
ID: CollectibleUniqueID{
ContractID: ContractID{
ChainID: w_common.ChainID(i % 4),
Address: common.BigToAddress(bigI),
},
TokenID: &bigint.BigInt{Int: bigI},
},
ContractType: generateContractType(i),
Provider: fmt.Sprintf("provider-%d", i),
Name: fmt.Sprintf("name-%d", i),
Description: fmt.Sprintf("description-%d", i),
Permalink: fmt.Sprintf("permalink-%d", i),
ImageURL: fmt.Sprintf("imageurl-%d", i),
ImagePayload: []byte(fmt.Sprintf("imagepayload-%d", i)),
AnimationURL: fmt.Sprintf("animationurl-%d", i),
AnimationMediaType: fmt.Sprintf("animationmediatype-%d", i),
Traits: []CollectibleTrait{
{
TraitType: fmt.Sprintf("traittype-%d", i),
Value: fmt.Sprintf("traitvalue-%d", i),
DisplayType: fmt.Sprintf("displaytype-%d", i),
MaxValue: fmt.Sprintf("maxvalue-%d", i),
},
{
TraitType: fmt.Sprintf("traittype-%d", i),
Value: fmt.Sprintf("traitvalue-%d", i),
DisplayType: fmt.Sprintf("displaytype-%d", i),
MaxValue: fmt.Sprintf("maxvalue-%d", i),
},
{
TraitType: fmt.Sprintf("traittype-%d", i),
Value: fmt.Sprintf("traitvalue-%d", i),
DisplayType: fmt.Sprintf("displaytype-%d", i),
MaxValue: fmt.Sprintf("maxvalue-%d", i),
},
},
BackgroundColor: fmt.Sprintf("backgroundcolor-%d", i),
TokenURI: fmt.Sprintf("tokenuri-%d", i),
CommunityID: fmt.Sprintf("communityid-%d", i%5),
}
result = append(result, newCollectible)
}
return result
}
func GenerateTestCollectiblesCommunityData(count int) []CollectibleCommunityInfo {
base := rand.Intn(100) // nolint: gosec
result := make([]CollectibleCommunityInfo, 0, count)
for i := base; i < count+base; i++ {
newCommunityInfo := CollectibleCommunityInfo{
PrivilegesLevel: token.PrivilegesLevel(i) % (token.CommunityLevel + 1),
}
result = append(result, newCommunityInfo)
}
return result
}
func GenerateTestCollectiblesOwnership(count int) []AccountBalance {
base := rand.Intn(100) // nolint: gosec
ret := make([]AccountBalance, 0, count)
for i := base; i < count+base; i++ {
ret = append(ret, AccountBalance{
Address: common.HexToAddress(fmt.Sprintf("0x%x", i)),
Balance: &bigint.BigInt{Int: big.NewInt(int64(i))},
})
}
return ret
}
func GenerateTestCollectionsData(count int) (result []CollectionData) {
base := rand.Intn(100) // nolint: gosec
result = make([]CollectionData, 0, count)
for i := base; i < count+base; i++ {
bigI := big.NewInt(int64(count))
traits := make(map[string]CollectionTrait)
for j := 0; j < 3; j++ {
traits[fmt.Sprintf("traittype-%d", j)] = CollectionTrait{
Min: float64(i+j) / 2,
Max: float64(i+j) * 2,
}
}
newCollection := CollectionData{
ID: ContractID{
ChainID: w_common.ChainID(i),
Address: common.BigToAddress(bigI),
},
ContractType: generateContractType(i),
Provider: fmt.Sprintf("provider-%d", i),
Name: fmt.Sprintf("name-%d", i),
Slug: fmt.Sprintf("slug-%d", i),
ImageURL: fmt.Sprintf("imageurl-%d", i),
ImagePayload: []byte(fmt.Sprintf("imagepayload-%d", i)),
Traits: traits,
CommunityID: fmt.Sprintf("community-%d", i),
}
result = append(result, newCollection)
}
return result
}
func GenerateTestCommunityInfo(count int) map[string]CommunityInfo {
base := rand.Intn(100) // nolint: gosec
result := make(map[string]CommunityInfo)
for i := base; i < count+base; i++ {
communityID := fmt.Sprintf("communityid-%d", i)
newCommunity := CommunityInfo{
CommunityName: fmt.Sprintf("communityname-%d", i),
CommunityColor: fmt.Sprintf("communitycolor-%d", i),
CommunityImage: fmt.Sprintf("communityimage-%d", i),
CommunityImagePayload: []byte(fmt.Sprintf("communityimagepayload-%d", i)),
}
result[communityID] = newCommunity
}
return result
}
func GenerateTestFullCollectiblesData(count int) []FullCollectibleData {
collectiblesData := GenerateTestCollectiblesData(count)
collectionsData := GenerateTestCollectionsData(count)
communityInfoMap := GenerateTestCommunityInfo(count)
communityInfo := make([]CommunityInfo, 0, count)
for _, info := range communityInfoMap {
communityInfo = append(communityInfo, info)
}
communityData := GenerateTestCollectiblesCommunityData(count)
ret := make([]FullCollectibleData, 0, count)
for i := 0; i < count; i++ {
// Ensure consistent ContracType
collectionsData[i].ContractType = collectiblesData[i].ContractType
ret = append(ret, FullCollectibleData{
CollectibleData: collectiblesData[i],
CollectionData: &collectionsData[i],
CommunityInfo: &communityInfo[i],
CollectibleCommunityInfo: &communityData[i],
Ownership: GenerateTestCollectiblesOwnership(rand.Intn(5) + 1), // nolint: gosec
})
}
return ret
}
@@ -0,0 +1,48 @@
package thirdparty
type HistoricalPrice struct {
Timestamp int64 `json:"time"`
Value float64 `json:"close"`
}
type TokenMarketValues struct {
MKTCAP float64 `json:"MKTCAP"`
HIGHDAY float64 `json:"HIGHDAY"`
LOWDAY float64 `json:"LOWDAY"`
CHANGEPCTHOUR float64 `json:"CHANGEPCTHOUR"`
CHANGEPCTDAY float64 `json:"CHANGEPCTDAY"`
CHANGEPCT24HOUR float64 `json:"CHANGEPCT24HOUR"`
CHANGE24HOUR float64 `json:"CHANGE24HOUR"`
}
type TokenDetails struct {
ID string `json:"Id"`
Name string `json:"Name"`
Symbol string `json:"Symbol"`
Description string `json:"Description"`
TotalCoinsMined float64 `json:"TotalCoinsMined"`
AssetLaunchDate string `json:"AssetLaunchDate"`
AssetWhitepaperURL string `json:"AssetWhitepaperUrl"`
AssetWebsiteURL string `json:"AssetWebsiteUrl"`
BuiltOn string `json:"BuiltOn"`
SmartContractAddress string `json:"SmartContractAddress"`
}
type MarketDataProvider interface {
FetchPrices(symbols []string, currencies []string) (map[string]map[string]float64, error)
FetchHistoricalDailyPrices(symbol string, currency string, limit int, allData bool, aggregate int) ([]HistoricalPrice, error)
FetchHistoricalHourlyPrices(symbol string, currency string, limit int, aggregate int) ([]HistoricalPrice, error)
FetchTokenMarketValues(symbols []string, currency string) (map[string]TokenMarketValues, error)
FetchTokenDetails(symbols []string) (map[string]TokenDetails, error)
}
type DataParsed struct {
Name string `json:"name"`
ID string `json:"id"`
Inputs map[string]string `json:"inputs"`
Signature string `json:"signature"`
}
type DecoderProvider interface {
Run(data string) (*DataParsed, error)
}
@@ -0,0 +1,41 @@
package utils
import "strings"
var renameMapping = map[string]string{
"STT": "SNT",
}
func RenameSymbols(symbols []string) (renames []string) {
for _, symbol := range symbols {
renames = append(renames, GetRealSymbol(symbol))
}
return
}
func GetRealSymbol(symbol string) string {
if val, ok := renameMapping[strings.ToUpper(symbol)]; ok {
return val
}
return strings.ToUpper(symbol)
}
func ChunkSymbols(symbols []string, chunkSizeOptional ...int) [][]string {
var chunks [][]string
chunkSize := 100
if len(chunkSizeOptional) > 0 {
chunkSize = chunkSizeOptional[0]
}
for i := 0; i < len(symbols); i += chunkSize {
end := i + chunkSize
if end > len(symbols) {
end = len(symbols)
}
chunks = append(chunks, symbols[i:end])
}
return chunks
}